From f85c555014ff988698d79c54a9755472c46b8912 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 06:50:32 -0800 Subject: [PATCH 01/49] JavaScript: run E4X XML literals and the XML, XMLList, Namespace and QName objects behind an option With `Options::e4x` set, vectorcraft-js runs ECMA-357 (2nd edition): XML and XMLList literals with embedded expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the four objects with their methods and settings. The option is off by default, and without it the syntax and globals do not exist. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a TypeError before the tree changes. A literal or XML string nested past `Options::max_depth` gives a SyntaxError, and a tree built past that depth stops at the heap limit. `XML.prototype.xpath` is not provided. --- crates/js/src/ast.rs | 109 +- crates/js/src/builtins.rs | 18 +- crates/js/src/bytecode.rs | 37 +- crates/js/src/compiler.rs | 120 +- crates/js/src/heap.rs | 114 ++ crates/js/src/interp.rs | 132 +- crates/js/src/lexer.rs | 22 +- crates/js/src/lib.rs | 17 +- crates/js/src/parser.rs | 59 +- crates/js/src/parser/xml.rs | 435 ++++++ crates/js/src/token.rs | 11 + crates/js/src/xml.rs | 2617 +++++++++++++++++++++++++++++++++ crates/js/src/xml_builtins.rs | 1072 ++++++++++++++ crates/js/src/xml_tests.rs | 731 +++++++++ docs/target-app-parity.md | 1 + 15 files changed, 5442 insertions(+), 53 deletions(-) create mode 100644 crates/js/src/parser/xml.rs create mode 100644 crates/js/src/xml.rs create mode 100644 crates/js/src/xml_builtins.rs create mode 100644 crates/js/src/xml_tests.rs diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs index f0dd6b44ba..ca991645b1 100644 --- a/crates/js/src/ast.rs +++ b/crates/js/src/ast.rs @@ -175,6 +175,61 @@ pub enum Expr { /// A parenthesized expression, kept so a rule about the bare form (a label, a directive) can /// tell them apart. Paren(ExprId), + /// An XML literal (`d`), or with `list` an XMLList literal (`<>…`). + XmlLiteral { + parts: Vec, + list: bool, + }, + /// An E4X name (`@a`, `*`, `ns::a`), the key of an [`Expr::Index`] or the operand of + /// [`Expr::XmlScopeName`] and [`Expr::XmlDescendants`]. + XmlName { + attribute: bool, + qualifier: Option, + name: XmlNamePart, + }, + /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope. + XmlScopeName(ExprId), + /// `object..name`. + XmlDescendants { + object: ExprId, + name: ExprId, + }, + /// `object.(predicate)`. + XmlFilter { + object: ExprId, + predicate: ExprId, + }, +} + +/// A piece of an XML literal, in source order. +#[derive(Clone, Debug, PartialEq)] +pub enum XmlPart { + /// Markup kept as written. + Text(Vec), + /// `{expression}` in element content: XML values keep their markup, other values are escaped. + Content(ExprId), + /// `{expression}` as an attribute's value. + Attribute(ExprId), + /// `{expression}` as a name or a run of attributes, its string used as markup. + Raw(ExprId), +} + +/// What stands before `::` in a qualified name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XmlQualifier { + /// `*::name`: any namespace. + Any, + Expr(ExprId), +} + +/// The name in an E4X name. +#[derive(Clone, Debug, PartialEq)] +pub enum XmlNamePart { + Ident(String), + /// `*`. + Star, + /// `[expression]`. + Computed(ExprId), } #[derive(Clone, Debug, PartialEq)] @@ -218,19 +273,55 @@ pub enum Stmt { Expr(ExprId), Block(Vec), Empty, - If { test: ExprId, consequent: StmtId, alternate: Option }, - DoWhile { body: StmtId, test: ExprId }, - While { test: ExprId, body: StmtId }, - For { init: Option, test: Option, update: Option, body: StmtId }, - ForIn { target: ForInTarget, object: ExprId, body: StmtId }, + If { + test: ExprId, + consequent: StmtId, + alternate: Option, + }, + DoWhile { + body: StmtId, + test: ExprId, + }, + While { + test: ExprId, + body: StmtId, + }, + For { + init: Option, + test: Option, + update: Option, + body: StmtId, + }, + /// `for (… in …)`, or with `each` the E4X `for each (… in …)`, which assigns the values. + ForIn { + target: ForInTarget, + object: ExprId, + body: StmtId, + each: bool, + }, + /// `default xml namespace = expression;`. + DefaultXmlNamespace(ExprId), Continue(Option), Break(Option), Return(Option), - With { object: ExprId, body: StmtId }, - Switch { discriminant: ExprId, cases: Vec }, - Labeled { label: Name, body: StmtId }, + With { + object: ExprId, + body: StmtId, + }, + Switch { + discriminant: ExprId, + cases: Vec, + }, + Labeled { + label: Name, + body: StmtId, + }, Throw(ExprId), - Try { block: Vec, handler: Option, finalizer: Option> }, + Try { + block: Vec, + handler: Option, + finalizer: Option>, + }, Debugger, } diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 984d3a1236..74c7b186df 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -131,7 +131,11 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { crate::string_builtins::install(interp)?; crate::number_builtins::install(interp)?; crate::regexp_builtins::install(interp)?; - crate::date_builtins::install(interp) + crate::date_builtins::install(interp)?; + if interp.options.e4x { + crate::xml_builtins::install(interp)?; + } + Ok(()) } // ---- helpers for the built-ins ---- @@ -218,7 +222,7 @@ const UNITS_PER_FUEL: usize = 16; /// against the longest string the heap limit allows, and has its bytes reserved before it is /// added. The buffer's bytes count toward the heap until `finish` or until the native call that /// made the builder returns. Everything the caller still needs must be reachable from the roots. -struct Builder { +pub(crate) struct Builder { units: Vec, /// Code units already reserved and charged to the heap. charged: usize, @@ -227,7 +231,7 @@ struct Builder { } impl Builder { - fn new() -> Self { + pub(crate) fn new() -> Self { Builder { units: Vec::new(), charged: 0, unpaid: 0 } } @@ -259,7 +263,7 @@ impl Builder { Ok(()) } - fn push_units(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult<()> { + pub(crate) fn push_units(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult<()> { self.make_room(interp, units.len())?; self.units.extend_from_slice(units); Ok(()) @@ -267,7 +271,7 @@ impl Builder { /// Appends `units` `count` times. Nothing is written for an empty `units`, however large /// `count` is. - fn push_repeated(&mut self, interp: &mut Interp, units: &[u16], count: u64) -> JsResult<()> { + pub(crate) fn push_repeated(&mut self, interp: &mut Interp, units: &[u16], count: u64) -> JsResult<()> { if units.is_empty() || count == 0 { return Ok(()); } @@ -279,14 +283,14 @@ impl Builder { Ok(()) } - fn push_str(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { + pub(crate) fn push_str(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { let units: Vec = s.encode_utf16().collect(); self.push_units(interp, &units) } /// The string built, whose bytes were charged as it grew. From here on the heap counts them /// as the string's. - fn finish(self, interp: &mut Interp) -> Value { + pub(crate) fn finish(self, interp: &mut Interp) -> Value { interp.held_bytes = interp.held_bytes.saturating_sub(2 * self.charged); Value::String(Str::from_units(self.units)) } diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index b23d9a525d..895d911716 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -145,6 +145,35 @@ pub(crate) enum Op { /// Pushes the completion value, which a `finally` block in script and eval code keeps and /// sets back with `SetCompletion` when it ends normally. GetCompletion, + /// `[qualifier] name` → an E4X name object. + XmlMakeName { + attribute: bool, + qualified: bool, + }, + /// `object name` → the descendants that match. + XmlDescendants, + /// `value` → the string an embedded expression puts in a literal (0 content, 1 attribute + /// value, 2 name). + XmlEscape(u8), + /// The `parts` strings → the XML or XMLList they spell. + XmlLiteral { + parts: u32, + list: bool, + }, + /// `name` → the value of the name on the nearest XML value in scope. + XmlScopeGet, + /// `object` → the filter's state. + XmlFilterStart, + /// `state` → `state item`, or jumps with `state` when the items are used up. + XmlFilterNext(u32), + /// `state value` → `state`, the current item kept when the value is truthy. + XmlFilterTest, + /// `state` → the XMLList of the items kept. + XmlFilterEnd, + /// `iterator name` → `iterator value`: the value under the name a `for each` got. + ForEachValue, + /// `value` → nothing: sets the default XML namespace of the running code. + SetDefaultNamespace, } /// What code is: a function body, a script, or eval code. @@ -267,7 +296,13 @@ fn effect(op: Op, finally_tables: &[Vec]) -> Result { let table = finally_tables.get(t as usize).ok_or_else(|| format!("finally table {t} out of range"))?; Effect { pops: 2, next: Some(0), jumps: table.iter().map(|&target| (target, 1)).collect() } } - Op::ForInNext(t) => Effect { pops: 1, next: Some(2), jumps: vec![(t, 1)] }, + Op::ForInNext(t) | Op::XmlFilterNext(t) => Effect { pops: 1, next: Some(2), jumps: vec![(t, 1)] }, + Op::XmlMakeName { qualified, .. } => e(1 + u32::from(qualified), 1), + Op::XmlDescendants | Op::XmlFilterTest => e(2, 1), + Op::XmlEscape(_) | Op::XmlScopeGet | Op::XmlFilterStart | Op::XmlFilterEnd => e(1, 1), + Op::XmlLiteral { parts, .. } => e(parts, 1), + Op::ForEachValue => e(2, 2), + Op::SetDefaultNamespace => e(1, 0), }) } diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index ae470be443..f0f171d59c 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -18,7 +18,7 @@ use std::rc::Rc; use crate::ast::{ AssignOp, Ast, BinaryOp, Expr, ExprId, ForInTarget, ForInit, FunctionId, FunctionKind, LogicalOp, PropertyValue, Stmt, StmtId, UnaryOp, UpdateOp, - VarDecl, + VarDecl, XmlNamePart, XmlPart, XmlQualifier, }; use crate::bytecode::{Code, CodeKind, Handler, Op, Source, fallthrough_effect, verify}; use crate::error::{SyntaxError, TOO_DEEP}; @@ -276,7 +276,14 @@ impl<'a> FnCompiler<'a> { } work.extend(block.iter().rev()); } - Stmt::Expr(_) | Stmt::Empty | Stmt::Continue(_) | Stmt::Break(_) | Stmt::Return(_) | Stmt::Throw(_) | Stmt::Debugger => {} + Stmt::Expr(_) + | Stmt::Empty + | Stmt::Continue(_) + | Stmt::Break(_) + | Stmt::Return(_) + | Stmt::Throw(_) + | Stmt::Debugger + | Stmt::DefaultXmlNamespace(_) => {} } } Ok(out) @@ -348,6 +355,11 @@ impl<'a> FnCompiler<'a> { Ok(()) } Stmt::Try { block, handler, finalizer } => self.try_stmt(block, handler.as_ref(), finalizer.as_deref()), + Stmt::DefaultXmlNamespace(e) => { + self.expr(*e)?; + self.emit(Op::SetDefaultNamespace); + Ok(()) + } } } @@ -467,14 +479,14 @@ impl<'a> FnCompiler<'a> { } self.jump(Op::Jump, head); } - Some(Stmt::ForIn { target, object, body }) => return self.for_in(target, *object, *body, labels), + Some(Stmt::ForIn { target, object, body, each }) => return self.for_in(target, *object, *body, labels, *each), _ => return internal("not a loop"), } self.bind(brk); Ok(()) } - fn for_in(&mut self, target: &ForInTarget, object: ExprId, body: StmtId, labels: Vec) -> CResult<()> { + fn for_in(&mut self, target: &ForInTarget, object: ExprId, body: StmtId, labels: Vec, each: bool) -> CResult<()> { if let ForInTarget::Var(d) = target { self.var_decls(std::slice::from_ref(d))?; } @@ -485,6 +497,9 @@ impl<'a> FnCompiler<'a> { let brk = self.new_label(); self.bind(head); self.jump(Op::ForInNext, brk); + if each { + self.emit(Op::ForEachValue); + } match target { ForInTarget::Var(d) => { let n = self.var_name(&d.name.name); @@ -827,10 +842,82 @@ impl<'a> FnCompiler<'a> { } } Expr::Paren(inner) => self.expr(*inner)?, + Expr::XmlLiteral { parts, list } => self.xml_literal(parts, *list)?, + Expr::XmlName { attribute, qualifier, name } => self.xml_name(*attribute, *qualifier, name)?, + Expr::XmlScopeName(name) => { + self.expr(*name)?; + self.at(self.ast.expr_span(id).start); + self.emit(Op::XmlScopeGet); + } + Expr::XmlDescendants { .. } | Expr::XmlFilter { .. } => self.chain(id)?, } Ok(()) } + fn xml_literal(&mut self, parts: &[XmlPart], list: bool) -> CResult<()> { + for part in parts { + match part { + XmlPart::Text(units) => { + let i = self.string(units); + self.emit(Op::String(i)); + } + XmlPart::Content(e) | XmlPart::Attribute(e) | XmlPart::Raw(e) => { + self.expr(*e)?; + self.emit(Op::XmlEscape(match part { + XmlPart::Content(_) => 0, + XmlPart::Attribute(_) => 1, + _ => 2, + })); + } + } + } + self.emit(Op::XmlLiteral { parts: len_u32(parts.len()), list }); + Ok(()) + } + + fn xml_name(&mut self, attribute: bool, qualifier: Option, name: &XmlNamePart) -> CResult<()> { + match qualifier { + Some(XmlQualifier::Any) => self.emit(Op::Null), + Some(XmlQualifier::Expr(e)) => self.expr(e)?, + None => {} + } + match name { + XmlNamePart::Ident(n) => { + let i = self.string(&n.encode_utf16().collect::>()); + self.emit(Op::String(i)); + } + XmlNamePart::Star => { + let i = self.string(&[u16::from(b'*')]); + self.emit(Op::String(i)); + } + XmlNamePart::Computed(e) => self.expr(*e)?, + } + self.emit(Op::XmlMakeName { attribute, qualified: qualifier.is_some() }); + Ok(()) + } + + /// `object.(predicate)` with the object already on the stack: each item is tested with itself + /// as the innermost scope, in list order. + fn xml_filter(&mut self, predicate: ExprId) -> CResult<()> { + self.emit(Op::XmlFilterStart); + let head = self.new_label(); + let done = self.new_label(); + self.bind(head); + let depth = self.depth; + self.jump(Op::XmlFilterNext, done); + self.emit(Op::PushWith); + self.env_depth += 1; + self.expr(predicate)?; + self.emit(Op::PopEnv); + self.env_depth -= 1; + self.emit(Op::XmlFilterTest); + self.jump(Op::Jump, head); + self.bind(done); + self.depth = depth; + self.emit(Op::XmlFilterEnd); + Ok(()) + } + fn args(&mut self, args: &[ExprId]) -> CResult<()> { for a in args { self.expr(*a)?; @@ -869,7 +956,9 @@ impl<'a> FnCompiler<'a> { let mut cur = id; loop { match self.ast.expr(cur) { - Some(Expr::Member { object, .. } | Expr::Index { object, .. }) => { + Some( + Expr::Member { object, .. } | Expr::Index { object, .. } | Expr::XmlDescendants { object, .. } | Expr::XmlFilter { object, .. }, + ) => { suffixes.push(cur); cur = self.ast.unparenthesized(*object); } @@ -910,6 +999,15 @@ impl<'a> FnCompiler<'a> { self.at(span.start); self.emit(if next_is_call { Op::GetIndexThis } else { Op::GetIndex }); } + Some(Expr::XmlDescendants { name, .. }) => { + self.expr(*name)?; + self.at(span.start); + self.emit(Op::XmlDescendants); + } + Some(Expr::XmlFilter { predicate, .. }) => { + self.at(span.start); + self.xml_filter(*predicate)?; + } Some(Expr::Call { args, .. }) => { let innermost = j + 1 == count; let has_this = if innermost { @@ -1163,9 +1261,15 @@ impl<'a> FnCompiler<'a> { for &(i, label) in &self.patches { let Some(target) = resolve(&self.labels, label) else { return internal("jump to an unplaced label") }; match self.ops.get_mut(i) { - Some(Op::Jump(t) | Op::JumpIfFalse(t) | Op::JumpIfTrue(t) | Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) | Op::ForInNext(t)) => { - *t = target - } + Some( + Op::Jump(t) + | Op::JumpIfFalse(t) + | Op::JumpIfTrue(t) + | Op::JumpIfFalseKeep(t) + | Op::JumpIfTrueKeep(t) + | Op::ForInNext(t) + | Op::XmlFilterNext(t), + ) => *t = target, _ => return internal("patching an instruction that is not a jump"), } } diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 1b2099fe16..496d9218d5 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -220,12 +220,84 @@ pub(crate) enum Class { Math, /// The `JSON` object (section 15.12). Json, + /// An XML value (ECMA-357 section 9.1). + Xml(Box), + /// An XMLList value (ECMA-357 section 9.2). + XmlList(Box), + Namespace(Box), + /// A QName; `attribute` marks an attribute name (ECMA-357 sections 13.3 and 9.3), and + /// `reference` a name made by E4X syntax, which only an XML value can be accessed with. + QName { + name: Box, + attribute: bool, + reference: bool, + }, + /// A `.( )` filter in progress. + XmlFilter(Box), +} + +/// What an XML value is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum XmlKind { + Element, + Attribute, + Text, + Comment, + Pi, +} + +/// A qualified name. `uri` is `None` for a name that matches any namespace. +#[derive(Clone, Debug)] +pub(crate) struct XmlName { + pub(crate) uri: Option, + pub(crate) local: Key, + pub(crate) prefix: Option, +} + +/// A namespace. `prefix` is `None` when it is undefined. +#[derive(Clone, Debug)] +pub(crate) struct XmlNs { + pub(crate) prefix: Option, + pub(crate) uri: Key, +} + +/// An XML value: a node of a tree, linked by object index. +#[derive(Clone)] +pub(crate) struct XmlNode { + pub(crate) kind: XmlKind, + pub(crate) name: Option, + pub(crate) parent: Option, + pub(crate) value: Str, + pub(crate) attrs: Vec, + pub(crate) children: Vec, + pub(crate) ns: Vec, +} + +/// An XMLList value: its items, and the object and name a write to an empty list targets. +#[derive(Clone)] +pub(crate) struct XmlListData { + pub(crate) items: Vec, + pub(crate) target: Option, + pub(crate) target_name: Option<(XmlName, bool)>, +} + +/// The state of a `.( )` filter: the items to test, the next one's position and those kept. +#[derive(Clone)] +pub(crate) struct XmlFilterState { + pub(crate) input: Vec, + pub(crate) pos: usize, + pub(crate) kept: Vec, } impl Class { /// The `[[Class]]` name `Object.prototype.toString` reports. pub(crate) fn name(&self) -> &'static str { match self { + Class::Xml(_) => "XML", + Class::XmlList(_) => "XMLList", + Class::Namespace(_) => "Namespace", + Class::QName { .. } => "QName", + Class::XmlFilter(_) => "Object", Class::Math => "Math", Class::Json => "JSON", Class::Ordinary | Class::ForIn(_) => "Object", @@ -303,6 +375,14 @@ pub(crate) const ENV_BYTES: usize = 64; pub(crate) const ROPE_BYTES: usize = 48; /// The estimated bytes of each argument a bound function holds. pub(crate) const BOUND_ARG_BYTES: usize = 24; +/// The estimated bytes of an XML node without its names and text. +pub(crate) const XML_NODE_BYTES: usize = 160; +/// The estimated bytes of an XML list without its items. +pub(crate) const XML_LIST_BYTES: usize = 96; +/// The estimated bytes of a reference from an XML node or list to another. +pub(crate) const XML_REF_BYTES: usize = 8; +/// The estimated bytes of a namespace an XML node holds. +pub(crate) const XML_NS_BYTES: usize = 48; /// The estimated bytes of a piece of code without its instructions, tables and strings. const CODE_BYTES: usize = 384; @@ -564,11 +644,45 @@ fn trace_object(o: &Object, work: &mut Vec, strings: &mut StringSizes) -> u work.extend(state.object.iter().map(|o| o.0)); strings.bytes += state.keys.iter().map(|k| string_bytes(k.len())).sum::(); } + Class::Xml(node) => { + work.extend(node.parent.iter().chain(&node.attrs).chain(&node.children).map(|o| o.0)); + strings.add(&node.value); + if let Some(name) = &node.name { + add_name(name, strings); + } + for ns in &node.ns { + add_ns(ns, strings); + } + extra = XML_NODE_BYTES + XML_REF_BYTES * (node.attrs.len() + node.children.len()) + XML_NS_BYTES * node.ns.len(); + } + Class::XmlList(list) => { + work.extend(list.target.iter().chain(&list.items).map(|o| o.0)); + if let Some((name, _)) = &list.target_name { + add_name(name, strings); + } + extra = XML_LIST_BYTES + XML_REF_BYTES * list.items.len(); + } + Class::Namespace(ns) => add_ns(ns, strings), + Class::QName { name, .. } => add_name(name, strings), + Class::XmlFilter(state) => { + work.extend(state.input.iter().chain(&state.kept).map(|o| o.0)); + extra = XML_LIST_BYTES + XML_REF_BYTES * (state.input.len() + state.kept.len()); + } _ => {} } OBJECT_BYTES + PROP_BYTES * o.props.len() + extra } +fn add_name(name: &XmlName, strings: &mut StringSizes) { + strings.add_key(&name.local); + name.uri.iter().chain(&name.prefix).for_each(|k| strings.add_key(k)); +} + +fn add_ns(ns: &XmlNs, strings: &mut StringSizes) { + strings.add_key(&ns.uri); + ns.prefix.iter().for_each(|k| strings.add_key(k)); +} + fn trace_env(e: &Env, work: &mut Vec, strings: &mut StringSizes) -> usize { match e { Env::Decl { bindings, outer } => { diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index b110a955b4..a28ffe46c5 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -72,6 +72,12 @@ pub(crate) struct Intrinsics { pub(crate) error_protos: [ObjId; 7], pub(crate) eval: ObjId, pub(crate) throw_type_error: ObjId, + /// The `XML` constructor, which holds the XML settings. + pub(crate) xml_ctor: ObjId, + pub(crate) xml_proto: ObjId, + pub(crate) xml_list_proto: ObjId, + pub(crate) namespace_proto: ObjId, + pub(crate) qname_proto: ObjId, } impl Intrinsics { @@ -89,6 +95,11 @@ impl Intrinsics { error_protos: [o; 7], eval: o, throw_type_error: o, + xml_ctor: o, + xml_proto: o, + xml_list_proto: o, + namespace_proto: o, + qname_proto: o, } } @@ -117,6 +128,11 @@ impl Intrinsics { self.date_proto, self.eval, self.throw_type_error, + self.xml_ctor, + self.xml_proto, + self.xml_list_proto, + self.namespace_proto, + self.qname_proto, ]; v.extend(self.error_protos); v @@ -128,8 +144,8 @@ pub(crate) struct Frame { pub(crate) pc: usize, /// Where this frame's operands start on the operand stack. base: usize, - env: EnvId, - var_env: EnvId, + pub(crate) env: EnvId, + pub(crate) var_env: EnvId, /// `with` and catch environments entered and not yet left. env_depth: u32, this: Value, @@ -179,6 +195,8 @@ pub(crate) struct Interp { pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. pub(crate) held: Rc, + /// The global code's default XML namespace URI (ECMA-357 section 12.1). + pub(crate) xml_default_uri: Key, } /// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's @@ -367,6 +385,7 @@ impl Interp { install_error: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), + xml_default_uri: Rc::from(Vec::new()), }; crate::builtins::install(&mut interp); interp.fuel = limits.fuel; @@ -405,16 +424,16 @@ impl Interp { // ---- the operand stack ---- - fn push(&mut self, v: Value) -> JsResult<()> { + pub(crate) fn push(&mut self, v: Value) -> JsResult<()> { self.stack.push(v); Ok(()) } - fn frame(&self) -> JsResult<&Frame> { + pub(crate) fn frame(&self) -> JsResult<&Frame> { self.frames.last().ok_or_else(|| Abrupt::Internal("no frame".into())) } - fn frame_mut(&mut self) -> JsResult<&mut Frame> { + pub(crate) fn frame_mut(&mut self) -> JsResult<&mut Frame> { self.frames.last_mut().ok_or_else(|| Abrupt::Internal("no frame".into())) } @@ -422,7 +441,7 @@ impl Interp { self.frames.last().map_or(0, |f| f.base) } - fn pop(&mut self) -> JsResult { + pub(crate) fn pop(&mut self) -> JsResult { if self.stack.len() <= self.base() { return internal("operand stack underflow"); } @@ -430,7 +449,7 @@ impl Interp { } /// The value `n` places below the top (0 is the top). - fn peek(&self, n: usize) -> JsResult { + pub(crate) fn peek(&self, n: usize) -> JsResult { let len = self.stack.len(); match len.checked_sub(n + 1).filter(|&i| i >= self.base()) { Some(i) => self.stack.get(i).cloned().ok_or_else(|| Abrupt::Internal("operand stack underflow".into())), @@ -450,7 +469,7 @@ impl Interp { } /// Replaces the top `n` values with `v`. - fn replace(&mut self, n: usize, v: Value) -> JsResult<()> { + pub(crate) fn replace(&mut self, n: usize, v: Value) -> JsResult<()> { self.drop_n(n)?; self.push(v) } @@ -638,7 +657,7 @@ impl Interp { Ok(None) } - fn jump_to(&mut self, target: u32) -> JsResult<()> { + pub(crate) fn jump_to(&mut self, target: u32) -> JsResult<()> { self.frame_mut()?.pc = target as usize; Ok(()) } @@ -730,6 +749,17 @@ impl Interp { let v = self.frame()?.completion.clone(); self.push(v) } + Op::XmlMakeName { attribute, qualified } => self.op_xml_make_name(attribute, qualified), + Op::XmlDescendants => self.op_xml_descendants(), + Op::XmlEscape(kind) => self.op_xml_escape(kind), + Op::XmlLiteral { parts, list } => self.op_xml_literal(parts, list), + Op::XmlScopeGet => self.op_xml_scope_get(), + Op::XmlFilterStart => self.op_xml_filter_start(), + Op::XmlFilterNext(t) => self.op_xml_filter_next(t), + Op::XmlFilterTest => self.op_xml_filter_test(), + Op::XmlFilterEnd => self.op_xml_filter_end(), + Op::ForEachValue => self.op_for_each_value(), + Op::SetDefaultNamespace => self.op_set_default_namespace(), Op::Return | Op::ReturnCompletion => internal("return reached the dispatcher"), } } @@ -798,7 +828,7 @@ impl Interp { // ---- variables ---- - fn resolve(&self, name: &[u16]) -> JsResult { + fn resolve(&mut self, name: &[u16]) -> JsResult { let mut cur = Some(self.frame()?.env); while let Some(e) = cur { match self.heap.env(e) { @@ -808,11 +838,11 @@ impl Interp { } cur = *outer; } - Some(Env::Obj { object, with_this, outer }) => { - if self.has_property(*object, name)? { - return Ok(Resolved::Obj { object: *object, with_this: *with_this }); + Some(&Env::Obj { object, with_this, outer }) => { + if self.scope_has(object, name)? { + return Ok(Resolved::Obj { object, with_this }); } - cur = *outer; + cur = outer; } None => return internal("environment out of range"), } @@ -842,7 +872,7 @@ impl Interp { match r { Resolved::Decl(e) => Ok((self.binding_value(e, name)?, Value::Undefined)), Resolved::Obj { object, with_this } => { - let v = self.get_from(object, name, &Value::Object(object))?; + let v = self.get_value(&Value::Object(object), name)?; Ok((v, if with_this { Value::Object(object) } else { Value::Undefined })) } Resolved::Unresolved => Err(self.not_defined(name)), @@ -869,6 +899,9 @@ impl Interp { _ => Ok(()), } } + Resolved::Obj { object, .. } if self.options.e4x && self.is_xml(&Value::Object(object)) => { + self.put_value(&Value::Object(object), name.clone(), v) + } Resolved::Obj { object, .. } => self.put(object, name.clone(), v, strict), Resolved::Unresolved if strict => Err(self.not_defined(name)), Resolved::Unresolved => { @@ -880,6 +913,13 @@ impl Interp { fn op_get_var(&mut self, n: u32, with_this: bool) -> JsResult<()> { let name = self.name(n)?; + if with_this + && self.options.e4x + && let Some((f, this)) = self.xml_scope_method(&name)? + { + self.push(f)?; + return self.push(this); + } let (v, this) = self.get_var(&name)?; self.push(v)?; if with_this { self.push(this) } else { Ok(()) } @@ -1056,6 +1096,9 @@ impl Interp { /// `base[k]` for any value (GetValue, section 8.7.1). pub(crate) fn get_value(&mut self, base: &Value, k: &[u16]) -> JsResult { + if self.options.e4x && self.is_xml(base) { + return self.xml_get(base, &Value::String(Str::from_units(k.to_vec()))); + } if let Value::String(s) = base { if is(k, "length") { return Ok(Value::Number(s.len() as f64)); @@ -1402,6 +1445,9 @@ impl Interp { /// PutValue on a property reference (section 8.7.2), whose base may be a primitive. fn put_value(&mut self, base: &Value, k: Key, v: Value) -> JsResult<()> { + if self.options.e4x && self.is_xml(base) { + return self.xml_put(base, &Value::String(Str::from_rc(k)), &v); + } let strict = self.strict(); match base { Value::Object(o) => self.put(*o, k, v, strict), @@ -1427,6 +1473,11 @@ impl Interp { fn op_get_member(&mut self, n: u32, with_this: bool) -> JsResult<()> { let name = self.name(n)?; let base = self.peek(0)?; + if with_this && self.options.e4x && self.is_xml(&base) { + let (f, this) = self.xml_method(&base, &name)?; + self.replace(1, f)?; + return self.push(this); + } let v = self.get_value(&base, &name)?; self.replace(1, v)?; if with_this { self.push(base) } else { Ok(()) } @@ -1455,6 +1506,9 @@ impl Interp { fn op_to_key(&mut self) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + return Ok(()); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; self.replace(1, Value::String(Str::from_rc(name))) @@ -1463,6 +1517,16 @@ impl Interp { fn op_get_index(&mut self, with_this: bool) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + if with_this { + let name = self.xml_method_key(&k)?; + let (f, this) = self.xml_method(&base, &name)?; + self.replace(2, f)?; + return self.push(this); + } + let v = self.xml_get(&base, &k)?; + return self.replace(2, v); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; let v = self.get_value(&base, &name)?; @@ -1474,6 +1538,10 @@ impl Interp { let v = self.peek(0)?; let k = self.peek(1)?; let base = self.peek(2)?; + if self.is_xml_access(&base, &k) { + self.xml_put(&base, &k, &v)?; + return self.replace(3, v); + } let name = self.to_key(&k)?; self.put_value(&base, name, v.clone())?; self.replace(3, v) @@ -1482,6 +1550,10 @@ impl Interp { fn op_delete_member(&mut self, n: u32) -> JsResult<()> { let name = self.name(n)?; let base = self.peek(0)?; + if self.options.e4x && self.is_xml(&base) { + let r = self.xml_delete(&base, &Value::String(Str::from_rc(name)))?; + return self.replace(1, Value::Bool(r)); + } let o = self.to_object(&base)?; let strict = self.strict(); let r = self.delete(o, &name, strict)?; @@ -1491,6 +1563,10 @@ impl Interp { fn op_delete_index(&mut self) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + let r = self.xml_delete(&base, &k)?; + return self.replace(2, Value::Bool(r)); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; let o = self.to_object(&base)?; @@ -1517,6 +1593,13 @@ impl Interp { fn op_inc_index(&mut self, prefix: bool, dec: bool) -> JsResult<()> { let k = self.peek(0)?; let base = self.peek(1)?; + if self.is_xml_access(&base, &k) { + let v = self.xml_get(&base, &k)?; + let old = self.to_number(&v)?; + let new = if dec { old - 1.0 } else { old + 1.0 }; + self.xml_put(&base, &k, &Value::Number(new))?; + return self.replace(2, Value::Number(if prefix { new } else { old })); + } self.check_coercible(&base, &[])?; let name = self.to_key(&k)?; let r = self.inc_property(&base, name, prefix, dec)?; @@ -1682,6 +1765,9 @@ impl Interp { /// For a String object, the heap budget for all its index names is checked before any is /// built. Each index name and each property visited takes a unit of fuel. fn enumerable_keys(&mut self, id: ObjId) -> JsResult> { + if self.options.e4x && self.is_xml(&Value::Object(id)) { + return self.xml_enumerate(id); + } let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); let mut cur = Some(id); @@ -1748,7 +1834,7 @@ impl Interp { state.pos += 1; let object = state.object; if let Some(o) = object - && self.has_property(o, &k)? + && self.scope_has(o, &k)? { return self.push(Value::String(Str::from_rc(k))); } @@ -2238,6 +2324,7 @@ impl Interp { Value::Bool(_) => "boolean", Value::Number(_) => "number", Value::String(_) => "string", + Value::Object(_) if self.options.e4x && self.is_xml(v) => "xml", Value::Object(_) if self.is_callable(v) => "function", Value::Object(_) => "object", } @@ -2399,6 +2486,9 @@ impl Interp { BinaryOp::StrictNotEq => Ok(Value::Bool(!strict_eq(a, b))), BinaryOp::Instanceof => self.instance_of(a, b).map(Value::Bool), BinaryOp::In => { + if self.options.e4x && self.is_xml(b) { + return self.xml_has(b, a).map(Value::Bool); + } let Value::Object(o) = b else { return Err(self.type_error("the right side of 'in' is not an object")) }; let k = self.to_key(a)?; self.has_property(*o, &k).map(Value::Bool) @@ -2411,6 +2501,9 @@ impl Interp { if let (Value::Number(x), Value::Number(y)) = (a, b) { return Ok(Value::Number(x + y)); } + if self.options.e4x && self.is_xml(a) && self.is_xml(b) { + return self.xml_concat(a, b); + } let pa = self.to_primitive(a, Hint::Default)?; let pb = self.to_primitive(b, Hint::Default)?; if matches!(pa, Value::String(_)) || matches!(pb, Value::String(_)) { @@ -2443,7 +2536,12 @@ impl Interp { } /// The abstract equality comparison (section 11.9.3). - fn loose_eq(&mut self, a: &Value, b: &Value) -> JsResult { + pub(crate) fn loose_eq(&mut self, a: &Value, b: &Value) -> JsResult { + if self.options.e4x + && let Some(r) = self.xml_loose_eq(a, b)? + { + return Ok(r); + } let (mut x, mut y) = (a.clone(), b.clone()); loop { match (&x, &y) { diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs index 8b82570a77..c47db56e8b 100644 --- a/crates/js/src/lexer.rs +++ b/crates/js/src/lexer.rs @@ -47,13 +47,30 @@ pub struct Lexer<'s> { preprocessor_lines: bool, /// The deepest nesting of groups a regular expression literal may have. max_depth: usize, + /// Read the E4X punctuators `..`, `::` and `@`. + e4x: bool, } impl<'s> Lexer<'s> { /// A lexer of `src`. `preprocessor_lines` turns on the reading of preprocessor lines; /// `max_depth` limits how deeply a regular expression literal's groups may nest. pub fn new(src: &'s [u16], preprocessor_lines: bool, max_depth: usize) -> Self { - Self { src, pos: 0, line: 1, line_start: 0, blank_line: true, preprocessor_lines, max_depth } + Self { src, pos: 0, line: 1, line_start: 0, blank_line: true, preprocessor_lines, max_depth, e4x: false } + } + + /// The same lexer, reading the E4X punctuators when `e4x` is true. + pub fn with_e4x(mut self, e4x: bool) -> Self { + self.e4x = e4x; + self + } + + /// Moves to source offset `pos`, at the given line and column (both from 1), after the parser + /// has read an XML literal up to there. + pub(crate) fn seek(&mut self, pos: usize, line: u32, column: u32) { + self.pos = pos; + self.line = line; + self.line_start = pos.saturating_sub(column.saturating_sub(1) as usize); + self.blank_line = false; } /// The code unit at `pos`, as a code point value. @@ -578,6 +595,9 @@ impl<'s> Lexer<'s> { fn punct(&mut self) -> Option { let rest = &self.src[self.pos..]; for (spelling, punct) in Punct::ALL { + if !self.e4x && matches!(punct, Punct::DotDot | Punct::ColonColon | Punct::At) { + continue; + } let bytes = spelling.as_bytes(); if rest.len() >= bytes.len() && rest.iter().zip(bytes).all(|(&u, &b)| u == u16::from(b)) { self.pos += bytes.len(); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index b883be432d..decf0315e0 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -37,6 +37,13 @@ //! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an //! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with //! [`RunErrorKind::Limit`], which a script's `try` cannot catch. +//! +//! With [`Options::e4x`], the parser also reads E4X (ECMA-357, second edition, December 2005): +//! XML and XMLList literals, the `.@`, `..`, `::` and `.( )` operators, `for each … in` and +//! `default xml namespace`; the realm then provides `XML`, `XMLList`, `Namespace`, `QName`, +//! `isXMLName` and `setDefaultXMLNamespace`. Without the option, the language and the built-ins are +//! those of ECMAScript 5.1 above. XML trees live in the heap, and parsing, copying, comparing and +//! serializing them use no native recursion and take fuel for each node. #![forbid(unsafe_code)] pub mod ast; @@ -59,10 +66,12 @@ mod regexp_vm; mod string_builtins; mod token; mod value; +mod xml; +mod xml_builtins; pub use ast::{ AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, - PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, + PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, XmlNamePart, XmlPart, XmlQualifier, }; pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; pub use lexer::{Goal, Lexer}; @@ -82,11 +91,13 @@ pub struct Options { pub max_depth: usize, /// The most UTF-16 code units a source may hold. pub max_source_units: usize, + /// Read and run E4X (ECMA-357). Off, XML syntax is an error and a realm has no XML built-ins. + pub e4x: bool, } impl Default for Options { fn default() -> Self { - Self { strict: false, preprocessor_lines: false, max_depth: DEFAULT_MAX_DEPTH, max_source_units: 64 << 20 } + Self { strict: false, preprocessor_lines: false, max_depth: DEFAULT_MAX_DEPTH, max_source_units: 64 << 20, e4x: false } } } @@ -190,3 +201,5 @@ mod interp_tests; mod tests; #[cfg(test)] mod text_tests; +#[cfg(test)] +mod xml_tests; diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index d56b756174..3c2ab3ed30 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -7,6 +7,8 @@ //! stack, and prefix operators, assignment chains, `else if` chains and labels with loops, so long //! runs of them do not nest at all. +mod xml; + use std::collections::{HashMap, HashSet}; use crate::LineTable; @@ -69,11 +71,13 @@ pub(crate) struct Parser<'s> { /// The next statement stands in a statement list, where a label's body may be a function /// declaration. Read and cleared as each statement starts. in_list: bool, + /// Read E4X syntax. + e4x: bool, } impl<'s> Parser<'s> { pub(crate) fn new(src: &'s [u16], options: &crate::Options, keep_tokens: bool) -> Result { - let lex = Lexer::new(src, options.preprocessor_lines, options.max_depth); + let lex = Lexer::new(src, options.preprocessor_lines, options.max_depth).with_e4x(options.e4x); let placeholder = Token { kind: TokenKind::Eof, span: Span::default(), line: 1, column: 1, newline_before: false, escaped: false, legacy_octal: false }; let mut parser = Self { @@ -90,6 +94,7 @@ impl<'s> Parser<'s> { tokens: keep_tokens.then(Vec::new), function_allowed: false, in_list: false, + e4x: options.e4x, }; parser.tok = parser.next_significant()?; Ok(parser) @@ -391,6 +396,7 @@ impl<'s> Parser<'s> { self.semicolon()?; Ok(self.ast.push_stmt(Stmt::Debugger, self.span_from(start))) } + Keyword::Default if self.at_default_xml_namespace() => self.parse_default_xml_namespace(start), _ => { let expr = self.parse_expression(false)?; self.semicolon()?; @@ -488,7 +494,14 @@ impl<'s> Parser<'s> { fn parse_for(&mut self, start: Span) -> Result { self.bump()?; + let each = self.e4x && self.tok.ident() == Some("each"); + if each { + self.bump()?; + } self.expect(Punct::LParen)?; + if each { + return self.parse_for_each(start); + } let init = if self.at(Punct::Semicolon) { None } else if self.at_keyword(Keyword::Var) { @@ -501,14 +514,14 @@ impl<'s> Parser<'s> { if self.ctx.strict && decl.init.is_some() { return Err(self.error_at_token(&self.tok, "a for-in variable cannot have an initializer in strict mode code")); } - return self.parse_for_in(ForInTarget::Var(decl), start); + return self.parse_for_in(ForInTarget::Var(decl), start, false); } Some(ForInit::Var(decls)) } else { let expr = self.parse_expression(true)?; if self.at_keyword(Keyword::In) { self.check_target(expr, "invalid left-hand side in for-in loop")?; - return self.parse_for_in(ForInTarget::Expr(expr), start); + return self.parse_for_in(ForInTarget::Expr(expr), start, false); } Some(ForInit::Expr(expr)) }; @@ -522,12 +535,27 @@ impl<'s> Parser<'s> { } /// The rest of `for (target in object) body`, from `in`. - fn parse_for_in(&mut self, target: ForInTarget, start: Span) -> Result { + fn parse_for_in(&mut self, target: ForInTarget, start: Span, each: bool) -> Result { self.expect_keyword(Keyword::In)?; let object = self.parse_expression(false)?; self.expect(Punct::RParen)?; let body = self.parse_loop_body()?; - Ok(self.ast.push_stmt(Stmt::ForIn { target, object, body }, self.span_from(start))) + Ok(self.ast.push_stmt(Stmt::ForIn { target, object, body, each }, self.span_from(start))) + } + + /// `for each (target in object) body`, from the target (ECMA-357 section 12.3). + fn parse_for_each(&mut self, start: Span) -> Result { + if self.at_keyword(Keyword::Var) { + self.bump()?; + let mut decls = self.parse_var_decls(true)?; + let (Some(decl), true) = (decls.pop(), decls.is_empty()) else { + return Err(self.error_at_token(&self.tok, "for each takes one variable")); + }; + return self.parse_for_in(ForInTarget::Var(decl), start, true); + } + let expr = self.parse_expression(true)?; + self.check_target(expr, "invalid left-hand side in for each loop")?; + self.parse_for_in(ForInTarget::Expr(expr), start, true) } /// `break` or `continue`, with an optional label on the same line, which must name a label @@ -608,7 +636,7 @@ impl<'s> Parser<'s> { let test = if self.at_keyword(Keyword::Case) { self.bump()?; Some(self.parse_expression(false)?) - } else if self.at_keyword(Keyword::Default) { + } else if self.at_keyword(Keyword::Default) && !self.at_default_xml_namespace() { if has_default { return Err(self.error_at_token(&self.tok, "more than one default clause in switch statement")); } @@ -620,7 +648,10 @@ impl<'s> Parser<'s> { }; self.expect(Punct::Colon)?; let mut body = Vec::new(); - while !(self.at_keyword(Keyword::Case) || self.at_keyword(Keyword::Default) || self.at(Punct::RBrace)) { + while !(self.at_keyword(Keyword::Case) + || (self.at_keyword(Keyword::Default) && !self.at_default_xml_namespace()) + || self.at(Punct::RBrace)) + { if self.tok.kind == TokenKind::Eof { return Err(self.unexpected()); } @@ -953,8 +984,13 @@ impl<'s> Parser<'s> { let mut expr = self.parse_primary()?; loop { match &self.tok.kind { + TokenKind::Punct(Punct::DotDot) => expr = self.parse_xml_descendants(expr)?, TokenKind::Punct(Punct::Dot) => { self.bump()?; + if self.e4x && self.at_e4x_member() { + expr = self.parse_xml_member(expr)?; + continue; + } let Some(name) = self.tok.identifier_name().map(str::to_string) else { return Err(self.error_at_token(&self.tok, format!("expected a property name but found {}", describe(&self.tok)))); }; @@ -1050,10 +1086,16 @@ impl<'s> Parser<'s> { self.expect(Punct::RParen)?; return Ok(self.ast.push_expr(Expr::Paren(inner), self.span_from(start))); } + TokenKind::Punct(Punct::Lt) if self.e4x => return self.parse_xml_literal(), + TokenKind::Punct(Punct::At | Punct::Star) if self.e4x => return self.parse_xml_scope_name(), _ => return Err(self.unexpected()), }; let token = self.bump()?; - Ok(self.ast.push_expr(expr, token.span)) + let id = self.ast.push_expr(expr, token.span); + if self.e4x && self.at(Punct::ColonColon) && matches!(token.kind, TokenKind::Ident(_)) { + return self.parse_xml_qualified_primary(id); + } + Ok(id) } fn parse_function_expression(&mut self) -> Result { @@ -1232,5 +1274,6 @@ fn describe(token: &Token) -> String { TokenKind::String(_) => "string".into(), TokenKind::RegExp { .. } => "regular expression".into(), TokenKind::Directive(_) => "preprocessor line".into(), + TokenKind::XmlLiteral => "XML literal".into(), } } diff --git a/crates/js/src/parser/xml.rs b/crates/js/src/parser/xml.rs new file mode 100644 index 0000000000..0b29bf8790 --- /dev/null +++ b/crates/js/src/parser/xml.rs @@ -0,0 +1,435 @@ +//! E4X syntax (ECMA-357 sections 11.1 and 12.1): literals, names and the operators on them. +//! +//! A literal is read by scanning the source units with an explicit stack of open tags, so tag +//! nesting uses no native recursion and is held to [`crate::Options::max_depth`]. Only an embedded +//! `{expression}` re-enters the expression parser, and that recursion is counted as any other. + +use super::{Parser, Result, describe}; +use crate::ast::{Expr, ExprId, Stmt, StmtId, XmlNamePart, XmlPart, XmlQualifier}; +use crate::error::TOO_DEEP; +use crate::token::{Keyword, Punct, Span, TokenKind}; +use crate::xml::{is_name_part, is_name_start, is_xml_space, scan_reference, valid_xml_units}; + +const LT: u16 = b'<' as u16; +const GT: u16 = b'>' as u16; +const SLASH: u16 = b'/' as u16; +const AMP: u16 = b'&' as u16; +const LBRACE: u16 = b'{' as u16; +const EQ: u16 = b'=' as u16; +const DQUOTE: u16 = b'"' as u16; +const SQUOTE: u16 = b'\'' as u16; + +/// A literal being read: the markup since the last embedded expression, and the finished parts. +#[derive(Default)] +struct Scan { + text: Vec, + parts: Vec, +} + +impl Scan { + fn flush(&mut self) { + if !self.text.is_empty() { + self.parts.push(XmlPart::Text(std::mem::take(&mut self.text))); + } + } +} + +impl Parser<'_> { + pub(super) fn at_default_xml_namespace(&self) -> bool { + self.e4x && self.peek().is_some_and(|t| t.ident() == Some("xml")) + } + + /// `default xml namespace = expression;`, from `default`. + pub(super) fn parse_default_xml_namespace(&mut self, start: Span) -> Result { + self.expect_keyword(Keyword::Default)?; + self.bump()?; + if self.tok.ident() != Some("namespace") { + return Err(self.error_at_token(&self.tok, format!("expected 'namespace' but found {}", describe(&self.tok)))); + } + self.bump()?; + self.expect(Punct::Eq)?; + let value = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::DefaultXmlNamespace(value), self.span_from(start))) + } + + /// After a `.`: the next token starts an E4X name or a filter rather than a property name. + pub(super) fn at_e4x_member(&self) -> bool { + match &self.tok.kind { + TokenKind::Punct(Punct::At | Punct::Star | Punct::LParen) => true, + _ => self.tok.identifier_name().is_some() && self.peek().is_some_and(|t| t.is_punct(Punct::ColonColon)), + } + } + + /// `object.@name`, `object.*`, `object.ns::name` or `object.(predicate)`, from the token after + /// the `.`. + pub(super) fn parse_xml_member(&mut self, object: ExprId) -> Result { + let start = self.ast.expr_span(object); + if self.at(Punct::LParen) { + self.bump()?; + let predicate = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + return Ok(self.ast.push_expr(Expr::XmlFilter { object, predicate }, self.span_from(start))); + } + let index = self.parse_xml_name()?; + Ok(self.ast.push_expr(Expr::Index { object, index }, self.span_from(start))) + } + + /// `object..name`, from the `..`. + pub(super) fn parse_xml_descendants(&mut self, object: ExprId) -> Result { + let start = self.ast.expr_span(object); + self.bump()?; + let name = self.parse_xml_name()?; + Ok(self.ast.push_expr(Expr::XmlDescendants { object, name }, self.span_from(start))) + } + + /// `@name`, `*` or `@*` standing alone as a primary expression. + pub(super) fn parse_xml_scope_name(&mut self) -> Result { + let start = self.tok.span; + let name = self.parse_xml_name()?; + Ok(self.ast.push_expr(Expr::XmlScopeName(name), self.span_from(start))) + } + + /// `qualifier::name` as a primary expression, from the `::`; `qualifier` is already read. + pub(super) fn parse_xml_qualified_primary(&mut self, qualifier: ExprId) -> Result { + let start = self.ast.expr_span(qualifier); + self.bump()?; + let name = self.parse_xml_name_part()?; + let qualifier = Some(XmlQualifier::Expr(qualifier)); + let id = self.ast.push_expr(Expr::XmlName { attribute: false, qualifier, name }, self.span_from(start)); + Ok(self.ast.push_expr(Expr::XmlScopeName(id), self.span_from(start))) + } + + /// An E4X name: `@`, then `*`, a name or `[expression]`, with an optional `qualifier::`. + fn parse_xml_name(&mut self) -> Result { + let start = self.tok.span; + let attribute = self.eat(Punct::At)?; + let first_is_star = self.at(Punct::Star); + let qualifier_ident = self.tok.identifier_name().map(str::to_string).filter(|_| matches!(self.tok.kind, TokenKind::Ident(_))); + let first = self.parse_xml_name_part()?; + let (qualifier, name) = if self.at(Punct::ColonColon) { + let qualifier = if first_is_star { + XmlQualifier::Any + } else if let Some(name) = qualifier_ident { + let span = self.span_from(start); + XmlQualifier::Expr(self.ast.push_expr(Expr::Ident(name), span)) + } else { + return Err(self.error_at_token(&self.tok, "expected a namespace name before '::'")); + }; + self.bump()?; + (Some(qualifier), self.parse_xml_name_part()?) + } else { + (None, first) + }; + Ok(self.ast.push_expr(Expr::XmlName { attribute, qualifier, name }, self.span_from(start))) + } + + fn parse_xml_name_part(&mut self) -> Result { + if self.eat(Punct::Star)? { + return Ok(XmlNamePart::Star); + } + if self.eat(Punct::LBracket)? { + let e = self.parse_expression(false)?; + self.expect(Punct::RBracket)?; + return Ok(XmlNamePart::Computed(e)); + } + let Some(name) = self.tok.identifier_name().map(str::to_string) else { + return Err(self.error_at_token(&self.tok, format!("expected an XML name but found {}", describe(&self.tok)))); + }; + self.bump()?; + Ok(XmlNamePart::Ident(name)) + } + + /// An XML or XMLList literal, from its `<` (the current token). + pub(super) fn parse_xml_literal(&mut self) -> Result { + let start = self.tok.span; + let token_slot = self.tokens.as_ref().map(Vec::len); + if let Some(tokens) = &mut self.tokens { + tokens.push(self.tok.clone()); + } + let mut pos = start.start as usize; + let list = self.unit(pos + 1) == Some(GT); + if list { + pos += 2; + } + let mut scan = Scan::default(); + // The name of each open element; `None` when it is computed. + let mut open: Vec>> = Vec::new(); + loop { + if !list && open.is_empty() && !scan.text.is_empty() | !scan.parts.is_empty() { + break; + } + let Some(c) = self.unit(pos) else { return Err(self.xml_error(pos, "unterminated XML literal")) }; + if c == LT { + if self.unit(pos + 1) == Some(SLASH) { + if open.is_empty() { + if list && self.unit(pos + 2) == Some(GT) { + pos += 3; + break; + } + return Err(self.xml_error(pos, "a closing tag has no matching open tag")); + } + pos = self.xml_close_tag(pos, &mut open, &mut scan)?; + } else if self.starts_with(pos, ""); + } + XmlKind::Pi => { + let target = self.xml_node(x)?.name.as_ref().map(|n| n.local.clone()).unwrap_or_else(|| Rc::from(Vec::new())); + out.push_str(self, ""); + } + XmlKind::Element => {} + } + let ns_mark = scope.len(); + let node = self.xml_node(x)?; + let declared = node.ns.clone(); + let attrs = node.attrs.clone(); + let name = node.name.clone().ok_or_else(|| Abrupt::Internal("element without a name".into()))?; + self.xml_take_fuel(declared.len().saturating_mul(scope.len() + 1))?; + let mut unnamed = Vec::new(); + for ns in declared { + if ns.prefix.is_none() { + unnamed.push(ns); + } else if !scope.iter().any(|a| a.uri == ns.uri && a.prefix == ns.prefix) { + self.xml_charge(XML_NS_BYTES)?; + scope.push(ns); + } + } + for ns in unnamed { + if !scope.iter().any(|a| a.uri == ns.uri) { + let prefix = fresh_prefix(scope); + self.xml_charge(XML_NS_BYTES)?; + scope.push(XmlNs { prefix: Some(prefix), uri: ns.uri }); + } + } + let prefix = self.out_prefix(&name, scope, false)?; + let mut attr_prefixes = Vec::new(); + for &a in &attrs { + self.use_fuel()?; + let an = self.xml_node(a)?.name.clone().ok_or_else(|| Abrupt::Internal("attribute without a name".into()))?; + attr_prefixes.push(self.out_prefix(&an, scope, true)?); + } + out.push_units(self, &[LT])?; + self.write_name(out, &prefix, &name.local)?; + for (&a, attr_prefix) in attrs.iter().zip(&attr_prefixes) { + let an = self.xml_node(a)?; + let local = an.name.as_ref().map(|q| q.local.clone()).unwrap_or_else(|| Rc::from(Vec::new())); + let value = an.value.flat(); + out.push_units(self, &[SPACE])?; + self.write_name(out, attr_prefix, &local)?; + out.push_units(self, &[EQ, DQUOTE])?; + self.write_escaped(out, &value, true)?; + out.push_units(self, &[DQUOTE])?; + } + for at in ns_mark..scope.len() { + let Some(ns) = scope.get(at).cloned() else { break }; + let ns_prefix = ns.prefix.unwrap_or_else(|| Rc::from(Vec::new())); + out.push_str(self, " xmlns")?; + if !ns_prefix.is_empty() { + out.push_units(self, &[COLON])?; + out.push_units(self, &ns_prefix)?; + } + out.push_units(self, &[EQ, DQUOTE])?; + self.write_escaped(out, &ns.uri, true)?; + out.push_units(self, &[DQUOTE])?; + } + let count = self.children_len(x)?; + if count == 0 { + scope.truncate(ns_mark); + return out.push_str(self, "/>"); + } + out.push_units(self, &[GT])?; + let first_is_text = match self.child_at(x, 0)? { + Some(c) => self.xml_kind(c)? == XmlKind::Text, + None => false, + }; + let pretty_children = pretty && (count > 1 || !first_is_text); + let child_indent = if pretty_children { indent.saturating_add(u64::from(settings.indent)) } else { 0 }; + stack.push(OutFrame { node: x, next: 0, indent, child_indent, pretty_children, ns_mark, prefix, local: name.local }); + Ok(()) + } + + fn write_node(&mut self, out: &mut Builder, root: ObjId, indent: u64, settings: &Settings, pretty: bool) -> JsResult<()> { + let mut scope: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + self.write_enter(out, root, indent, settings, pretty, &mut scope, &mut stack)?; + while let Some(frame) = stack.last_mut() { + let (node, next, child_indent, pretty_children) = (frame.node, frame.next, frame.child_indent, frame.pretty_children); + frame.next += 1; + match self.child_at(node, next)? { + Some(child) => { + if pretty_children { + out.push_units(self, &[LF])?; + } + self.write_enter(out, child, child_indent, settings, pretty && pretty_children, &mut scope, &mut stack)?; + } + None => { + let Some(frame) = stack.pop() else { break }; + if frame.pretty_children { + out.push_units(self, &[LF])?; + out.push_repeated(self, &[SPACE], frame.indent)?; + } + out.push_units(self, &[LT, SLASH])?; + self.write_name(out, &frame.prefix, &frame.local)?; + out.push_units(self, &[GT])?; + scope.truncate(frame.ns_mark); + } + } + } + Ok(()) + } + + /// ToXMLString (ECMA-357 section 10.2). `pretty` overrides the setting when it is given. + pub(crate) fn xml_string_of(&mut self, v: &Value, pretty: Option) -> JsResult { + let settings = self.xml_settings()?; + let pretty = pretty.unwrap_or(settings.pretty); + let mut out = Builder::new(); + match self.classify(v) { + Xv::Node(n) => self.write_node(&mut out, n, 0, &settings, pretty)?, + Xv::List(l) => { + for i in 0..self.list_len(l)? { + self.use_fuel()?; + let Some(item) = self.list_at(l, i)? else { break }; + if i > 0 && pretty { + out.push_units(self, &[LF])?; + } + self.write_node(&mut out, item, 0, &settings, pretty)?; + } + } + Xv::Other => return Err(self.not_xml()), + } + let Value::String(s) = out.finish(self) else { return Err(Abrupt::Internal("not a string".into())) }; + Ok(s) + } + + // ---- the parser ---- + + fn syntax(&mut self, message: &str) -> Abrupt { + self.throw(ErrorType::Syntax, message) + } + + fn decode_into(&mut self, src: &[u16], from: usize, to: usize, out: &mut Vec, attribute: bool) -> JsResult<()> { + self.xml_text_fuel(to.saturating_sub(from))?; + let mut pos = from; + while pos < to { + let Some(&c) = src.get(pos) else { break }; + if c == AMP { + let Some((after, ch)) = scan_reference(src, pos).filter(|(after, _)| *after <= to) else { + return Err(self.syntax("invalid XML character or entity reference")); + }; + push_char(out, ch); + pos = after; + continue; + } + if pos + 1 < to && starts_pair(c, src.get(pos + 1).copied()) { + out.extend_from_slice(src.get(pos..pos + 2).unwrap_or_default()); + pos += 2; + continue; + } + if !is_xml_char(u32::from(c)) { + return Err(self.syntax("invalid character in XML text")); + } + pos += 1; + match (c, attribute) { + (CR, _) => { + if src.get(pos) == Some(&LF) { + pos += 1; + } + out.push(if attribute { SPACE } else { LF }); + } + (TAB | LF, true) => out.push(SPACE), + _ => out.push(c), + } + } + Ok(()) + } + + fn find_from(src: &[u16], from: usize, needle: &[u16]) -> Option { + src.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| from + i) + } + + fn read_name(&mut self, src: &[u16], pos: usize) -> JsResult { + if !src.get(pos).copied().is_some_and(is_name_start) { + return Err(self.syntax("invalid XML name")); + } + let mut end = pos + 1; + while src.get(end).copied().is_some_and(is_name_part) { + end += 1; + } + if !valid_xml_units(src.get(pos..end).unwrap_or_default()) { + return Err(self.syntax("invalid XML name")); + } + Ok(end) + } + + fn check_units(&mut self, src: &[u16], from: usize, to: usize) -> JsResult<()> { + if valid_xml_units(src.get(from..to).unwrap_or_default()) { + return Ok(()); + } + Err(self.syntax("invalid character in XML text")) + } + + /// Splits `prefix:local`. + fn split_name<'a>(&mut self, raw: &'a [u16]) -> JsResult<(Option<&'a [u16]>, &'a [u16])> { + let Some(colon) = raw.iter().position(|&c| c == COLON) else { return Ok((None, raw)) }; + let (prefix, rest) = raw.split_at(colon); + let local = rest.get(1..).unwrap_or_default(); + if !is_ncname(prefix) || !is_ncname(local) { + return Err(self.syntax("invalid XML qualified name")); + } + Ok((Some(prefix), local)) + } + + fn resolve_prefix(&mut self, prefix: &[u16], own: &[XmlNs], open: &[ObjId]) -> JsResult> { + if prefix == key("xml").as_ref() { + return Ok(Some(key(XML_URI))); + } + self.xml_take_fuel(own.len() + open.len() + 1)?; + if let Some(ns) = prefix_in_scope(own, prefix) { + return Ok(Some(ns.uri.clone())); + } + for &e in open.iter().rev() { + let node = self.xml_node(e)?; + let len = node.ns.len(); + if let Some(ns) = prefix_in_scope(&node.ns, prefix) { + return Ok(Some(ns.uri.clone())); + } + self.xml_take_fuel(len / UNITS_PER_FUEL)?; + } + Ok(None) + } + + fn place_parsed(&mut self, node: ObjId, open: &[ObjId], roots: &mut Vec) -> JsResult<()> { + if let Some(&top) = open.last() { + self.xml_charge_link(node)?; + self.xml_node_mut(top)?.children.push(node); + self.xml_node_mut(node)?.parent = Some(top); + } else { + self.xml_keep(node); + self.xml_charge(XML_REF_BYTES)?; + roots.push(node); + } + Ok(()) + } + + fn flush_text(&mut self, text: &mut Vec, ignore_whitespace: bool, open: &[ObjId], roots: &mut Vec) -> JsResult<()> { + if text.is_empty() { + return Ok(()); + } + if ignore_whitespace && text.iter().all(|&c| is_xml_space(c)) { + text.clear(); + return Ok(()); + } + let t = self.new_text_node(Str::from_units(std::mem::take(text)), None)?; + self.place_parsed(t, open, roots) + } + + fn parse_open_tag( + &mut self, + src: &[u16], + pos: usize, + open: &mut Vec, + names: &mut Vec<(usize, usize)>, + roots: &mut Vec, + ) -> JsResult { + if open.len() >= self.options.max_depth { + return Err(self.syntax(TOO_DEEP)); + } + let name_start = pos + 1; + let name_end = self.read_name(src, name_start)?; + let raw_name = src.get(name_start..name_end).unwrap_or_default(); + let (elem_prefix, elem_local) = self.split_name(raw_name)?; + let mut at = name_end; + let mut raw_attrs: Vec<(usize, usize, Vec)> = Vec::new(); + let closes = loop { + let before = at; + while src.get(at).copied().is_some_and(is_xml_space) { + at += 1; + } + match src.get(at).copied() { + None => return Err(self.syntax("unterminated XML tag")), + Some(SLASH) if src.get(at + 1) == Some(>) => { + at += 2; + break true; + } + Some(GT) => { + at += 1; + break false; + } + Some(_) if at == before => return Err(self.syntax("expected white space before an attribute")), + Some(_) => { + self.use_fuel()?; + let end = self.read_name(src, at)?; + let mut p = end; + while src.get(p).copied().is_some_and(is_xml_space) { + p += 1; + } + if src.get(p) != Some(&EQ) { + return Err(self.syntax("expected '=' after an attribute name")); + } + p += 1; + while src.get(p).copied().is_some_and(is_xml_space) { + p += 1; + } + let quote = match src.get(p).copied() { + Some(q @ (DQUOTE | SQUOTE)) => q, + _ => return Err(self.syntax("expected a quoted attribute value")), + }; + let value_start = p + 1; + let Some(value_end) = Self::find_from(src, value_start, &[quote]) else { + return Err(self.syntax("unterminated attribute value")); + }; + if src.get(value_start..value_end).is_some_and(|v| v.contains(<)) { + return Err(self.syntax("'<' cannot appear in an attribute value")); + } + let mut value = Vec::new(); + self.decode_into(src, value_start, value_end, &mut value, true)?; + raw_attrs.push((at, end, value)); + at = value_end + 1; + } + } + }; + self.xml_charge(XML_NS_BYTES.saturating_mul(raw_attrs.len()))?; + let mut ns: Vec = Vec::new(); + let mut seen_decl: HashSet> = HashSet::new(); + for (start, end, value) in &raw_attrs { + let raw = src.get(*start..*end).unwrap_or_default(); + if raw == key("xmlns").as_ref() { + if !seen_decl.insert(Vec::new()) { + return Err(self.syntax("a namespace is declared twice")); + } + ns.push(XmlNs { prefix: Some(Rc::from(Vec::new())), uri: Rc::from(value.as_slice()) }); + } else if let (Some(p), local) = self.split_name(raw)? + && p == key("xmlns").as_ref() + { + if value.is_empty() { + return Err(self.syntax("a namespace prefix cannot be declared with an empty URI")); + } + if !seen_decl.insert(local.to_vec()) { + return Err(self.syntax("a namespace is declared twice")); + } + ns.push(XmlNs { prefix: Some(Rc::from(local)), uri: Rc::from(value.as_slice()) }); + } + } + let uri = match elem_prefix { + Some(p) => match self.resolve_prefix(p, &ns, open)? { + Some(u) => u, + None => return Err(self.syntax("an XML name uses an undeclared namespace prefix")), + }, + None => match self.resolve_prefix(&[], &ns, open)? { + Some(u) => u, + None => self.xml_default_uri()?, + }, + }; + let name = XmlName { uri: Some(uri), local: Rc::from(elem_local), prefix: Some(Rc::from(elem_prefix.unwrap_or_default())) }; + let element = self.new_xml_node(XmlNode { + kind: XmlKind::Element, + name: Some(name), + parent: None, + value: Str::from(""), + attrs: Vec::new(), + children: Vec::new(), + ns, + })?; + self.place_parsed(element, open, roots)?; + let mut unique: HashSet<(Key, Vec)> = HashSet::new(); + for (start, end, value) in raw_attrs { + self.use_fuel()?; + let raw = src.get(start..end).unwrap_or_default(); + let (prefix, local) = self.split_name(raw)?; + if raw == key("xmlns").as_ref() || prefix.is_some_and(|p| p == key("xmlns").as_ref()) { + continue; + } + let (auri, stored_prefix): (Key, Key) = match prefix { + Some(p) => { + let own = self.xml_node(element)?.ns.clone(); + match self.resolve_prefix(p, &own, open)? { + Some(u) => (u, Rc::from(p)), + None => return Err(self.syntax("an XML name uses an undeclared namespace prefix")), + } + } + None => (Rc::from(Vec::new()), Rc::from(Vec::new())), + }; + if !unique.insert((auri.clone(), local.to_vec())) { + return Err(self.type_error("an XML element has two attributes with the same name")); + } + let a = self.new_xml_node(XmlNode { + kind: XmlKind::Attribute, + name: Some(XmlName { uri: Some(auri), local: Rc::from(local), prefix: Some(stored_prefix) }), + parent: Some(element), + value: Str::from_units(value), + attrs: Vec::new(), + children: Vec::new(), + ns: Vec::new(), + })?; + self.xml_charge_link(a)?; + self.xml_node_mut(element)?.attrs.push(a); + } + if !closes { + open.push(element); + names.push((name_start, name_end)); + } + Ok(at) + } + + fn parse_close_tag(&mut self, src: &[u16], pos: usize, open: &mut Vec, names: &mut Vec<(usize, usize)>) -> JsResult { + let name_start = pos + 2; + let name_end = self.read_name(src, name_start)?; + let mut at = name_end; + while src.get(at).copied().is_some_and(is_xml_space) { + at += 1; + } + if src.get(at) != Some(>) { + return Err(self.syntax("expected '>' at the end of a closing tag")); + } + let Some((open_start, open_end)) = names.pop() else { + return Err(self.syntax("a closing tag has no matching open tag")); + }; + if src.get(open_start..open_end) != src.get(name_start..name_end) { + return Err(self.syntax("the closing tag does not match the open tag")); + } + open.pop(); + Ok(at + 1) + } + + /// Parses XML text into nodes, kept reachable through the temporary roots until the caller's + /// scope ends. + pub(crate) fn xml_parse(&mut self, source: &[u16]) -> JsResult> { + let mut held = HeldBytes::new(self); + held.add(self, string_bytes(source.len()).saturating_mul(2))?; + let settings = self.xml_settings()?; + let mut src = source; + let mut offset_start = 0; + while src.get(offset_start).copied().is_some_and(is_xml_space) { + offset_start += 1; + } + if src.get(offset_start..).is_some_and(|r| r.starts_with(&parser_units("")) else { + return Err(self.syntax("unterminated XML declaration")); + }; + src = src.get(end + 2..).unwrap_or_default(); + } + let mut pos = 0; + let mut roots = Vec::new(); + let mut open: Vec = Vec::new(); + let mut names: Vec<(usize, usize)> = Vec::new(); + let mut text: Vec = Vec::new(); + while let Some(&c) = src.get(pos) { + self.use_fuel()?; + if c != LT { + let end = Self::find_from(src, pos, &[LT]).unwrap_or(src.len()); + let section_end = parser_units("]]>"); + if src.get(pos..end).is_some_and(|run| run.windows(3).any(|w| w == section_end)) { + return Err(self.syntax("']]>' cannot appear in XML text")); + } + self.decode_into(src, pos, end, &mut text, false)?; + pos = end; + continue; + } + let rest = src.get(pos..).unwrap_or_default(); + if rest.starts_with(&parser_units("")) else { + return Err(self.syntax("unterminated CDATA section")); + }; + self.xml_text_fuel(end - pos)?; + self.check_units(src, pos + 9, end)?; + text.extend_from_slice(src.get(pos + 9..end).unwrap_or_default()); + pos = end + 3; + continue; + } + self.flush_text(&mut text, settings.ignore_whitespace, &open, &mut roots)?; + if rest.starts_with(&parser_units("&y]]>');"; + truth(&format!("{src} t.children().length() === 5")); + truth(&format!("{src} t.children()[0].nodeKind() === 'text' && t.children()[0].toString() === 'x'")); + truth(&format!("{src} t.children()[1].nodeKind() === 'comment' && t.comments().length() === 1")); + truth(&format!("{src} t.children()[2].nodeKind() === 'processing-instruction' && t.children()[2].name().localName === 'pi'")); + truth(&format!("{src} t.children()[3].nodeKind() === 'element'")); + truth(&format!("{src} t.children()[4].toString() === '&y'")); + truth(&format!("{src} t.@p == '1' && t.@q == '<' && t.attributes().length() === 2 && t.@p.nodeKind() === 'attribute'")); +} + +#[test] +fn serialization_matches_the_fixed_markup() { + assert_eq!(string("(1).toXMLString()"), "\n 1\n \n"); + assert_eq!(string("XML.prettyPrinting = false; (1).toXMLString()"), "1"); + assert_eq!(string("XML.prettyIndent = 4; ().toXMLString()"), "\n \n"); + assert_eq!(string("(<&>).toXMLString()"), "<&>"); + assert_eq!(string("().toXMLString()"), "\n \n"); + assert_eq!(string("(<>).toXMLString()"), "\n"); +} + +#[test] +fn embedded_expressions_are_escaped_by_position() { + assert_eq!(string("var v = '<&>'; ({v}).toXMLString()"), "<&>"); + assert_eq!(string("var v = 'a\"<'; ().toXMLString()"), ""); + assert_eq!(string("var n = 'tag'; (<{n} x=\"1\">t).toXMLString()"), "t"); + assert_eq!(string("var c = 1; ({c}).toXMLString()"), "\n 1\n"); + assert_eq!(string("var a = 'p=\"1\"'; ().toXMLString()"), ""); + truth("var i = 0; var x = {i++}{i++}; x == '01' && i === 2"); + truth("var o = {t: 1}; ({o.t ? 'y' : 'n'}) == 'y'"); + truth("({'}'}) == '}'"); + truth("({1 < 2}) == 'true'"); +} + +// ---- selectors and updates ---- + +#[test] +fn selectors_return_the_documented_lists() { + let s = |rest: &str| truth(&format!("{BOOKS} {rest}")); + s("xml.book.length() === 4 && xml.book[0].author == 'Farmer'"); + s("xml.book[0].@category == 'COOKING' && xml.book.@category.length() === 4"); + s("xml.book[1].@category.toString() === 'CHILDREN'"); + s("xml.*.length() === 4 && xml.book[0].*.length() === 3"); + s("xml.book[0].@*.length() === 1"); + s("xml.nothing.length() === 0 && xml.@nothing.length() === 0"); + s("xml..title.length() === 4 && xml..title == xml.descendants('title')"); + s("xml..@lang.length() === 4 && xml..@category.length() === 4"); + s("xml.book.(@category == 'CHILDREN').length() === 2"); + s("xml.book.(year > 1899).title.length() === 2"); + s("xml.book.(author == 'Baum').title == 'Oz'"); + s("xml.*::book.length() === 4"); + s("var n = null; xml.n::book.length() === 4"); + s("xml['book'].length() === 4 && xml.book[0]['author'] == 'Farmer' && xml.book[0]['@category'] == 'COOKING'"); + s("var k = 'category'; xml.book[0].@[k] == 'COOKING'"); +} + +#[test] +fn the_top_sample_has_three_children_two_elements_and_seven_descendants() { + truth(&format!("{TOP} x.children().length() === 3")); + truth(&format!("{TOP} x.elements().length() === 2")); + truth(&format!("{TOP} x.descendants().length() === 7")); +} + +#[test] +fn a_filter_tests_each_item_once_in_order() { + truth( + "var seen = []; var l = <>123; var r = l.(seen.push(toString()), true); seen.join() === '1,2,3' && r.length() === 3", + ); + truth("().(false).length() === 0"); + assert_eq!(thrown_name("({}).(1)"), "TypeError"); + assert_eq!(thrown_name("var o = {}; o.@a"), "TypeError"); + assert_eq!(thrown_name("var o = {}; o..a"), "TypeError"); +} + +#[test] +fn assignment_and_delete_update_the_tree() { + let s = |rest: &str| truth(&format!("{BOOKS} {rest}")); + s( + "xml.book[2].author = 'Dodgeson'; xml.book[2].author.length() === 1 && xml.book[2].author == 'Dodgeson' && xml.book[2].*[1].localName() === 'author'", + ); + s("xml.book[2].author[0] = 'First'; xml.book[2].author.length() === 3 && xml.book[2].author[0] == 'First'"); + s("xml.book[1].year = 1901; xml.book[1].year == '1901'"); + s("xml.book[1].rating = '*****'; xml.book[1].*[3].localName() === 'rating' && xml.book[1].rating == '*****'"); + s("xml.book[1].@category = 'FANTASY'; xml.book[1].@category == 'FANTASY'"); + s("xml.book[1].@added = 'y'; xml.book[1].@added == 'y' && xml.book[1].attributes().length() === 2"); + s("delete xml.book[2].author; xml.book[2].author.length() === 0"); + s("delete xml.book[2].author[1]; xml.book[2].author.length() === 2 && xml.book[2].author[1] == 'C'"); + s("delete xml.book[2].@category; xml.book[2].@category.length() === 0"); + s("xml.book[0] = ; xml.book.length() === 4 && xml.book[0].@id == 'new' && xml.book[0].parent() === xml"); + assert_eq!(thrown_name("var x = ; x[0] = 1"), "TypeError"); + assert_eq!(thrown_name("var x = ; delete x[0]"), "TypeError"); + truth("var x = ; x.b.c = 'd'; x.b.c == 'd'"); + truth("var x = ; x.b += 'v'; x.b == 'v'"); + truth("var x = 1; x.n++; x.n == '2'"); + truth("var x = ; x.@t++; x.@t == '2'"); +} + +#[test] +fn operators_follow_the_standard() { + truth("var l = + ; typeof l === 'xml' && l.length() === 2"); + truth("var l = <> + ; l.length() === 2"); + truth("typeof === 'xml' && typeof <> === 'xml' && typeof new Namespace() === 'object'"); + truth("1 == 1 && !(1 == 2)"); + truth(" == "); + truth("1 == '1' && '1' == 1 && 1 == 1"); + truth("!(01 == 1) && true == true && null == null && undefined == undefined && !(x == null)"); + truth("<> == <> && !(<> == <>)"); + truth("new XMLList() == undefined"); + truth("new Namespace('p', 'u') == new Namespace('q', 'u') && new QName('u', 'a') == new QName('u', 'a')"); + truth("var s = ''; for (var i in <>) s += i; s === '01'"); + truth("var s = ''; for each (var v in <>12) s += v; s === '12'"); + truth("var s = ''; for each (var v in [3, 4]) s += v; s === '34'"); + truth("var s = ''; for each (var v in 7) s += v; s === '7'"); + truth("var x = 5; x.length == '5' && x.length() === 1"); + truth("var x = abc; x.charAt(1) === 'b'"); + truth("var l = <>abc; l.toUpperCase() === 'ABC'"); + assert_eq!(thrown_name("().noSuchMethod()"), "TypeError"); + assert_eq!(thrown_name("(<>).nodeKind()"), "TypeError"); + truth("with (1) { b == '1' }"); +} + +#[test] +fn the_default_namespace_is_scoped_to_the_function() { + truth( + "var ns = new Namespace('u'); default xml namespace = ns; var x = ; x.name().uri === 'u' && x.b.length() === 1 && x.b.name().uri === 'u'", + ); + truth("default xml namespace = 'u'; ().@p.name().uri === ''"); + truth( + "function f() { default xml namespace = 'u'; return ().name().uri; } \ + f() === 'u' && ().name().uri === ''", + ); + truth("function f() { default xml namespace = 'u'; return function () { return ().name().uri; }; } f()() === 'u'"); + truth("setDefaultXMLNamespace(new Namespace('p', 'v')); ().name().uri === 'v' && new QName('b').uri === 'v'"); + truth("default xml namespace = 'u'; new QName('a').uri === 'u' && new QName('*').uri === null"); + truth("var x = ; x.b.length() === 0"); + truth("var x = ; var n = new Namespace('u'); x.n::b.length() === 1"); + truth( + "var soap = new Namespace('http://schemas.xmlsoap.org/soap/envelope/'); default xml namespace = soap; \ + var m = ; \ + m.@soap::encodingStyle == 'e' && m.Body.length() === 1", + ); +} + +// ---- built-ins ---- + +#[test] +fn the_constructors_keep_identity_or_copy_as_specified() { + truth("XML().nodeKind() === 'text' && XML(null) == '' && XML(undefined) == ''"); + truth("var x = ; XML(x) === x && new XML(x) !== x && new XML(x) == x"); + truth("var l = <>; XML(l) === l[0]"); + assert_eq!(thrown_name("XML(<>)"), "TypeError"); + assert_eq!(thrown_name("XML('')"), "SyntaxError"); + assert_eq!(thrown_name("XML('')"), "SyntaxError"); + truth("XMLList().length() === 0 && XMLList('').length() === 2"); + truth("var l = <>; XMLList(l) === l && new XMLList(l) !== l && new XMLList(l)[0] === l[0]"); + truth("var n = new Namespace('p', 'u'); Namespace(n) === n && new Namespace(n) !== n && new Namespace(n).prefix === 'p'"); + truth("var n = new Namespace(); n.prefix === '' && n.uri === '' && n.toString() === ''"); + truth("var n = new Namespace('u'); n.prefix === undefined && n.uri === 'u'"); + truth("new Namespace('1bad', 'u').prefix === undefined"); + assert_eq!(thrown_name("new Namespace('p', '')"), "TypeError"); + truth("var q = new QName('u', 'a'); QName(q) === q && new QName(q) !== q && q.uri === 'u' && q.localName === 'a'"); + truth("var q = new QName(new Namespace('p', 'u'), 'a'); q.uri === 'u' && q.toString() === 'u::a'"); + truth("new QName('*').toString() === '*::*' && new QName(null, 'a').uri === null && new QName().localName === ''"); + truth("isXMLName('abc') && isXMLName('a-b') && !isXMLName('a.b') && !isXMLName('$a') && !isXMLName('1a') && !isXMLName('')"); + truth("().localName() === 'a.b'"); +} + +#[test] +fn the_settings_are_per_realm_and_restore() { + truth( + "var d = XML.defaultSettings(); !d.ignoreComments && !d.ignoreProcessingInstructions && d.ignoreWhitespace && d.prettyPrinting && d.prettyIndent === 2", + ); + truth("var s = XML.settings(); XML.prettyIndent = 7; XML.settings().prettyIndent === 7 && (XML.setSettings(s), XML.prettyIndent === 2)"); + truth("XML.prettyIndent = 7; XML.setSettings(); XML.prettyIndent === 2"); + truth("XML.setSettings({ignoreWhitespace: false, prettyIndent: 'bad'}); !XML.ignoreWhitespace && XML.prettyIndent === 2"); + truth("(new XML('')).children().length() === 2"); + truth("XML.ignoreComments = true; XML.ignoreProcessingInstructions = true; (new XML('')).children().length() === 0"); + truth("XML.ignoreWhitespace = false; (new XML(' ')).children().length() === 3"); + truth("(new XML(' ')).children().length() === 1"); + truth("(new XML(' t ')).toString() === ' t '"); + let mut first = realm(); + first.run("XML.prettyIndent = 9").unwrap(); + assert_eq!(realm().run("XML.prettyIndent"), Ok(JsValue::Number(2.0))); +} + +#[test] +fn the_xml_methods_behave_as_specified() { + truth("var x = ; x.appendChild() === x && x.*[1].localName() === 'c' && x.c.parent() === x"); + truth("var x = ; x.appendChild('t'); x.text() == 't'"); + truth("var x = ; x.prependChild(); x.*[0].localName() === 'c'"); + truth("var x = ; x.insertChildAfter(x.b[0], ); x.*[1].localName() === 'c'"); + truth("var x = ; x.insertChildBefore(x.d[0], ); x.*[1].localName() === 'c'"); + truth("var x = ; x.insertChildAfter(null, ); x.*[0].localName() === 'c'"); + truth("var x = ; x.insertChildBefore(null, ); x.*[1].localName() === 'c'"); + truth("var x = ; x.insertChildAfter(, ) === undefined && x.*.length() === 1"); + truth("var x = ; x.c[0].childIndex() === 1 && x.childIndex() === -1"); + truth("var x = ; x.attribute('p') == '1' && x.attributes().length() === 1"); + truth("var x = t; x.child('b').length() === 1 && x.child(1) == 't' && x.child(9).length() === 0"); + truth("var x = ; x.copy() !== x && x.copy() == x && x.copy().parent() === null && x.b.copy().parent() === undefined"); + truth("var c = ().b[0].copy(); c.parent() === null"); + truth("var x = ; x.contains()"); + truth("var x = ; x.hasOwnProperty('b') && !x.hasOwnProperty('c') && x.hasComplexContent() && !x.hasSimpleContent()"); + truth("var x = t; !x.hasComplexContent() && x.hasSimpleContent() && x.length() === 1"); + truth( + "var x = ; x.b[0].inScopeNamespaces().length === 1 && x.namespaceDeclarations()[0].prefix === 'p' && x.b[0].namespaceDeclarations().length === 0", + ); + truth("var x = ; x.namespace('p').uri === 'u' && x.namespace('q') === undefined && x.namespace().uri === ''"); + truth("var x = ; x.addNamespace(new Namespace('p', 'u')); x.namespace('p').uri === 'u'"); + truth("var x = ; x.removeNamespace(new Namespace('p', 'u')); x.namespace('p') === undefined"); + truth("var x = ; x.removeNamespace(new Namespace('p', 'u')); x.namespace('p').uri === 'u'"); + truth("var x = ; x.name().localName === 'a' && x.localName() === 'a'"); + truth("(t).*[0].name() === null && (t).*[0].localName() === null"); + truth("var x = 1; x.b[0].appendChild('2'); x.b[0].*.length() === 2 && (x.normalize(), x.b[0].*.length() === 1) && x.b == '12'"); + truth("var x = ; x.b[0].parent() === x && x.parent() === null"); + truth("var x = ; x.processingInstructions().length() === 2 && x.processingInstructions('r').length() === 1"); + truth("var x = ; x.propertyIsEnumerable(0) && !x.propertyIsEnumerable(1)"); + truth("var x = ; x.replace('b', ); x.*.length() === 2 && x.*[0].localName() === 'z' && x.*[1].localName() === 'c'"); + truth("var x = ; x.replace(0, 't'); x.* == 't'"); + truth("var x = ; x.replace('nothing', ) === x && x.b.length() === 1"); + truth("var x = ; x.setChildren( + ) === x && x.*.length() === 2 && x.c.length() === 1"); + truth("var x = ; x.setLocalName('b'); x.localName() === 'b'"); + truth("var x = ; x.setName('b'); x.localName() === 'b'"); + truth("var x = ; x.setNamespace(new Namespace('p', 'u')); x.name().uri === 'u' && x.toXMLString() === ''"); + assert_eq!(thrown_name("().setLocalName('1 x')"), "TypeError"); + assert_eq!(thrown_name("().setName('a b')"), "TypeError"); + assert_eq!(string("(1).toString()"), "1"); + assert_eq!(string("().toString()"), "\n \n"); + truth("var x = ; x.valueOf() === x"); + truth("().xpath === undefined || ().xpath.length() === 0"); + truth("var x = ; x.b.attributes !== undefined"); +} + +#[test] +fn the_xml_list_methods_work_item_by_item() { + let l = "var l = <>s;"; + truth(&format!("{l} l.length() === 2 && l.attribute('p').length() === 2 && l.attributes().length() === 2")); + truth(&format!("{l} l.child('x').length() === 1 && l.children().length() === 4 && l.child(0).length() === 2")); + truth(&format!("{l} l.comments().length() === 1 && l.elements().length() === 2 && l.text() == 's'")); + truth(&format!("{l} l.descendants().length() === 4")); + truth(&format!("{l} l.contains(s) && !l.contains()")); + truth(&format!("{l} l.copy()[0] !== l[0] && l.copy() == l")); + truth(&format!("{l} l.hasOwnProperty(0) && !l.hasOwnProperty(5) && l.propertyIsEnumerable(1) && !l.propertyIsEnumerable(2)")); + truth(&format!("{l} l.hasComplexContent() && !l.hasSimpleContent()")); + truth(&format!("{l} l.parent() === undefined && l.valueOf() === l")); + truth("var x = ; x.*.parent() === x"); + truth("new XMLList().hasSimpleContent() && !new XMLList().hasComplexContent() && new XMLList().parent() === undefined"); + truth("var l = <>12; l.normalize() === l && l.toString() === '1\\n2'"); + truth("var r = 1; var l = r.a; l[0] = 2; r.a == '2' && l[0] == '2'"); + truth("var r = 1; r.a[1] = 'n'; r.a.length() === 2 && r.a[1] == 'n' && r.*[1].parent() === r"); + truth("var r = 1; var list = r.b; list.c = 'v'; r.b.c == 'v'"); + truth("var r = ; r.a.@t[0] = '2'; r.a.@t == '2'"); + truth("var r = 1; r.a[0] = <>

; r.*.length() === 3 && r.*[0].localName() === 'p'"); +} + +#[test] +fn inserting_a_node_moves_it_out_of_its_old_parent() { + truth("var b = ; var a = ; var c = b.c[0]; a.appendChild(c); b.c.length() === 0 && a.c.length() === 1 && c.parent() === a"); + truth("var a = ; a.appendChild(a.b[0]); a.*.length() === 2 && a.*[0].localName() === 'c'"); + truth("var b = ; var a = ; var c = b.c[0]; a.appendChild(c); c.appendChild(b); a..*.length() === 2 && b.parent() === c"); +} + +#[test] +fn namespaces_survive_serialization() { + assert_eq!(string("var x = ; x.toXMLString()"), "\n \n"); + assert_eq!(string("var x = ; x.toXMLString()"), ""); + truth("var x = ; var y = new XML(x.toXMLString()); x == y && y.*[0].name().uri === 'u'"); + truth( + "var q = new QName(new Namespace('u'), 'a'); var x = new XML(''); x.appendChild(); x.*[0].setName(q); (new XML(x.toXMLString())).*[0].name().uri === 'u'", + ); + truth( + "var x = ; x.@[new QName('u', 'b')] = '1'; var y = new XML(x.toXMLString()); y.attributes()[0].name().uri === 'u' && y.attributes()[0] == '1'", + ); +} + +#[test] +fn the_garbage_collector_keeps_trees_and_lists_alive() { + let mut r = realm(); + r.run("var x = text; var l = x.b; var ns = new Namespace('p', 'u'); var q = new QName('u', 'n');").unwrap(); + r.collect_garbage(); + assert_eq!(r.run("x.b.@t == '1' && l[0] == 'text' && l[0].parent() === x && ns.uri === 'u' && q.localName === 'n'"), Ok(JsValue::Bool(true))); + r.run("x = l = ns = q = null;").unwrap(); + let live = r.collect_garbage(); + assert!(live < 100_000, "{live}"); +} + +// ---- hostile input ---- + +#[test] +fn malformed_literals_are_located_syntax_errors() { + let cases = [ + "var a = ", + "", + "", + "", + "", + "", + "&bad;", + "�", + "{1", + "{", + "<1x/>", + "", + "", + ",<", + "", + "", + "<>", + "", + ]; + for src in cases { + let e = parse(src, &e4x()).expect_err(src); + assert!(e.line >= 1 && e.column >= 1, "{src}: {e:?}"); + } + let e = parse("var a =\n ;", &e4x()).unwrap_err(); + assert_eq!(e.line, 2); +} + +#[test] +fn bad_xml_text_is_a_catchable_syntax_error() { + for s in [ + "", + "", + "&nope;", + "", + "", + "", + "", + "]]>", + "", + ] { + assert_eq!(thrown_name(&format!("new XML({s:?})")), "SyntaxError", "{s}"); + } + assert_eq!(thrown_name("new XML('')"), "TypeError"); +} + +#[test] +fn unpaired_surrogates_are_rejected_in_xml_text() { + let lone = "String.fromCharCode(0xD800)"; + let low = "String.fromCharCode(0xDC00)"; + for value in [ + format!("'' + {lone} + ''"), + format!("'' + {low} + ''"), + format!("''"), + format!("''"), + format!("''"), + format!("''"), + format!("'<' + {lone} + '/>'"), + ] { + assert_eq!(thrown_name(&format!("new XML({value})")), "SyntaxError", "{value}"); + } + assert_eq!(thrown_name(&format!("().setLocalName({lone})")), "TypeError"); + let e = parse("var a = <\u{FFFD}/>;", &e4x()).unwrap_err(); + assert_eq!((e.line, e.column), (1, 10)); + truth("var p = String.fromCharCode(0xD83D, 0xDE00); var x = new XML('' + p + ''); x == p && x.@b == p"); + truth("var p = String.fromCharCode(0xD83D, 0xDE00); new XML('<' + p + '/>').localName() === p"); +} + +#[test] +fn collections_during_parsing_and_copying_keep_new_nodes() { + let src = "for (var i = 0; i < 200; i++) { \ + var x = new XML('t'); var c = x.copy(); \ + if (c.@p != '1' || c.b.@r != '3' || c.*.length() !== 3 || x.@q != '2') throw new Error('lost'); \ + } 'done'"; + for spare in (16_000..20_000).step_by(37) { + let mut r = realm(); + let live = r.collect_garbage(); + r.set_limits(Limits { heap_bytes: live + spare, ..Limits::default() }); + assert_eq!(r.run(src), Ok(JsValue::String("done".into())), "{spare}"); + } +} + +#[test] +fn collections_during_updates_and_queries_keep_new_values() { + let src = "for (var i = 0; i < 60; i++) { \ + var x = new XML('t'); var c = x.copy(); \ + var l = <>{x.b}{'t'}; \ + c.appendChild(); c.prependChild('s'); c.insertChildAfter(c.b[0], ); c.insertChildBefore(c.b[0], 'n'); \ + c.replace(0, 1); c.n.o = 'v'; c.@t = 'w'; c.z[0] = ; c.g.h = 'deep'; \ + var f = x.*.(nodeKind() === 'element'); var d = x..*; var j = x + c; var m = x.b + ; \ + var ns = new Namespace('p', 'u'); var qn = new QName(ns, 'a'); c.addNamespace(ns); c.setNamespace(ns); c.setName(qn); \ + var scope = c.inScopeNamespaces(); var decl = c.namespaceDeclarations(); c.normalize(); \ + var text = c.toXMLString(); var again = new XML(text); var children = c.children(); var el = c.elements(); var attrs = c.attributes(); \ + var filled = new XMLList(l); var copied = l.copy(); c.setChildren(); \ + if (x.@p != '1' || x.*.length() !== 3 || f.length() !== 1 || j.length() !== 2 || m.length() !== 2) throw new Error('lost'); \ + if (l.length() !== 3 || copied.length() !== 3 || c.*.length() !== 1 || scope.length < 1 || d.length() !== 3) throw new Error('lost'); \ + } 'done'"; + let mut first_error = None; + let mut completed = 0; + for spare in (40_000..56_000).step_by(211) { + let mut r = realm(); + let live = r.collect_garbage(); + r.set_limits(Limits { heap_bytes: live + spare, ..Limits::default() }); + let got = r.run(src); + if got == Ok(JsValue::String("done".into())) { + completed += 1; + } else if !matches!(&got, Err(e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) { + first_error = Some(format!("{spare}: {got:?}")); + break; + } + } + assert_eq!(first_error, None); + assert!(completed > 0); +} + +#[test] +fn a_scope_name_skips_a_list_without_that_name() { + truth("var outer = ; var inner = <>; with (outer) with (inner) { @id == '1' }"); + truth("var outer = ; var inner = <>; with (outer) with (inner) { @id == '2' }"); +} + +#[test] +fn a_bracket_key_converts_to_a_string_before_it_names_a_child() { + truth("var x = 1; x[true] == '1' && (true in x) && !(false in x)"); + truth("var x = 1; x[null] == '1' && x[undefined].length() === 0 && x[1.5].length() === 0 && x[-1].length() === 0"); + truth("var x = ; x[false] = 'v'; x['false'] == 'v' && (delete x[false], x.*.length() === 0)"); + assert_eq!(thrown_name("().attribute(true)"), "TypeError"); + assert_eq!(thrown_name("().descendants(null)"), "TypeError"); +} + +fn kind_of(limits: Limits, src: &str) -> RunErrorKind { + match limited(limits).run(src) { + Ok(v) => panic!("{} completed with {v:?}", &src[..src.len().min(80)]), + Err(e) => e.kind, + } +} + +fn on_small_stack(f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new().stack_size(512 << 10).spawn(f).unwrap().join().unwrap() +} + +#[test] +fn deep_trees_end_in_errors_on_a_small_stack() { + on_small_stack(|| { + let tags = 100_000; + let literal = format!("{}{}", "".repeat(tags), "".repeat(tags)); + let e = parse(&literal, &e4x()).unwrap_err(); + assert_eq!(e.message, crate::TOO_DEEP); + let text = format!("new XML('{literal}')"); + assert_eq!(limited(Limits::default()).run(&text).map_err(|e| e.message.contains(crate::TOO_DEEP)), Err(true)); + let chain = "var x = ; for (var i = 0; i < 100000; i++) { var y = ; y.appendChild(x); x = y; }"; + assert_eq!(kind_of(Limits::default(), chain), RunErrorKind::Limit(LimitKind::Heap)); + let within = "var x = ; for (var i = 0; i < 150; i++) { var y = ; y.appendChild(x); x = y; } \ + var c = x.copy(); [c == x, x.descendants().length(), x.toXMLString().length > 0, (x.normalize(), 1)].join()"; + assert_eq!(limited(Limits::default()).run(within), Ok(JsValue::String("true,150,true,1".into()))); + }); +} + +#[test] +fn cyclic_insertion_throws_and_leaves_the_tree_alone() { + for src in [ + "x.appendChild(x)", + "x.b[0].appendChild(x)", + "x.b[0].c[0].appendChild(x.b[0])", + "x.b[0].prependChild(x)", + "x.b[0].insertChildAfter(null, x)", + "x.b[0].insertChildBefore(null, x)", + ] { + let setup = "var x = ; var before = x.toXMLString();"; + let script = format!("{setup} var n; try {{ {src}; n = 'none' }} catch (e) {{ n = e.name }} x.toXMLString() === before ? n : 'changed'"); + assert_eq!(string(&script), "TypeError", "{src}"); + } +} + +#[test] +fn replace_inserts_a_copy_so_an_ancestor_can_be_the_value() { + truth("var x = ; x.b[0].replace(0, x); x.b.a.b.c.length() === 1 && x.b.a[0] !== x && x.descendants().length() === 4"); +} + +const TIGHT: Limits = Limits { fuel: 200_000, heap_bytes: 1 << 20, frames: 200, reentry: 8 }; + +#[test] +fn big_values_end_with_the_heap_limit() { + let attrs = format!("var x = new XML('');", (0..50_000).map(|i| format!("a{i}=\"1\"")).collect::>().join(" ")); + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, &attrs), RunErrorKind::Limit(LimitKind::Heap)); + let wide = "var l = <>; for (;;) { l = l + l; }"; + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, wide), RunErrorKind::Limit(LimitKind::Heap)); + let giant = "var s = 'x'; for (var i = 0; i < 40; i++) s += s; new XML('' + s + '')"; + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, giant), RunErrorKind::Limit(LimitKind::Heap)); + let escaped = "var s = '<'; for (var i = 0; i < 17; i++) s += s; ({s}).toXMLString().length"; + assert_eq!(kind_of(Limits { fuel: 10_000_000, ..TIGHT }, escaped), RunErrorKind::Limit(LimitKind::Heap)); +} + +#[test] +fn catastrophic_queries_end_with_the_fuel_limit() { + let build = "var x = ; for (var i = 0; i < 2000; i++) x.appendChild();"; + let limits = Limits { fuel: 1_500_000, heap_bytes: 64 << 20, frames: 200, reentry: 8 }; + let filter = format!("{build} for (;;) x.a.(true);"); + assert_eq!(kind_of(limits.clone(), &filter), RunErrorKind::Limit(LimitKind::Fuel)); + let descendants = format!("{build} for (;;) x..a;"); + assert_eq!(kind_of(limits.clone(), &descendants), RunErrorKind::Limit(LimitKind::Fuel)); + let serialize = format!("{build} for (;;) x.toXMLString();"); + assert_eq!(kind_of(limits, &serialize), RunErrorKind::Limit(LimitKind::Fuel)); +} + +#[test] +fn limits_cannot_be_caught_by_the_script() { + assert_eq!(kind_of(TIGHT, "try { for (;;) ().toXMLString(); } catch (e) { 1 }"), RunErrorKind::Limit(LimitKind::Fuel)); +} + +#[test] +fn an_interrupt_stops_serialization() { + let mut r = realm(); + r.run("var x = ; for (var i = 0; i < 500; i++) x.appendChild();").unwrap(); + let interrupt = r.interrupt(); + r.define_function("stop", move |_| { + interrupt.set(); + Ok(JsValue::Undefined) + }); + assert_eq!(r.run("stop(); x.toXMLString()").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted))); +} + +// ---- generated input ---- + +const PIECES: &[&str] = &[ + "<", + ">", + "/", + "", + "", + "", + "", + "", + "<>", + "", + "{", + "}", + "{1}", + "'", + "\"", + "&", + "&", + "A", + "&bad;", + "", + "", + "", + "@", + "::", + "..", + ".(", + ")", + "*", + "/x/", + " ", + "\n", + "a", + "1", + "=", + "ns", + ":", + "xmlns", + "x.@a", + "x..y", + "default xml namespace", +]; + +fn tree_markup(shape: &[u8]) -> String { + let mut out = String::new(); + let mut open: Vec = Vec::new(); + out.push_str(""); + for (i, &b) in shape.iter().enumerate() { + match b % 6 { + 0 if open.len() < 20 => { + out.push_str(&format!("")); + open.push(i); + } + 1 => { + if let Some(e) = open.pop() { + out.push_str(&format!("")); + } + } + 2 => out.push_str(&format!("")), + 3 => out.push_str(&format!("t{b}&")), + 4 => out.push_str(&format!("")), + _ => out.push_str(&format!("")), + } + } + while let Some(e) = open.pop() { + out.push_str(&format!("")); + } + out.push_str(""); + out +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn source_mixes_parse_or_give_a_located_error(parts in proptest::collection::vec(0..PIECES.len(), 0..40)) { + let src: String = parts.iter().map(|&i| PIECES[i]).collect(); + if let Err(e) = parse(&src, &e4x()) { + prop_assert!(e.line >= 1 && e.column >= 1, "{src:?}: {e:?}"); + } + } + + #[test] + fn random_programs_never_reach_an_internal_error(parts in proptest::collection::vec(0..PIECES.len(), 0..40)) { + let src: String = format!("var x = ; {}", parts.iter().map(|&i| PIECES[i]).collect::()); + let mut r = limited(Limits { fuel: 20_000, heap_bytes: 1 << 20, frames: 200, reentry: 8 }); + if let Err(e) = r.run(&src) { + prop_assert!(e.kind != RunErrorKind::Internal, "{src:?}: {e:?}"); + } + } + + #[test] + fn xml_text_parses_or_throws(parts in proptest::collection::vec(0..PIECES.len(), 0..40)) { + let text: String = parts.iter().map(|&i| PIECES[i]).collect(); + let quoted = format!("{text:?}"); + let mut r = limited(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + if let Err(e) = r.run(&format!("new XML({quoted})")) { + prop_assert!(e.kind == RunErrorKind::Thrown, "{quoted}: {e:?}"); + } + } + + #[test] + fn serialized_trees_parse_to_an_equal_tree(shape in proptest::collection::vec(any::(), 0..80)) { + let markup = tree_markup(&shape); + let quoted = format!("{markup:?}"); + let plain = format!("XML.prettyPrinting = false; XML.ignoreWhitespace = false; var x = new XML({quoted}); var y = new XML(x.toXMLString()); x.normalize(); y.normalize(); x == y"); + prop_assert_eq!(limited(Limits::default()).run(&plain), Ok(JsValue::Bool(true)), "{}", markup); + let pretty = format!("var x = new XML({quoted}); var y = new XML(x.toXMLString()); x.toXMLString() === y.toXMLString()"); + prop_assert_eq!(limited(Limits::default()).run(&pretty), Ok(JsValue::Bool(true)), "{}", markup); + } + + #[test] + fn insert_sequences_stay_acyclic(steps in proptest::collection::vec((0usize..8, 0usize..8), 0..40)) { + let mut src = String::from("var n = []; for (var i = 0; i < 8; i++) n.push(new XML('')); var bad = 0;"); + for (a, b) in steps { + src.push_str(&format!("try {{ n[{a}].appendChild(n[{b}]); }} catch (e) {{ if (e.name !== 'TypeError') bad++; }}")); + } + src.push_str("var ok = bad === 0; for (var j = 0; j < 8; j++) { ok = ok && n[j].toXMLString().length > 0 && n[j].descendants().length() < 8; } ok"); + let got = limited(Limits::default()).run(&src); + prop_assert_eq!(got, Ok(JsValue::Bool(true)), "{}", src); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d13cd142ea..10f9b939ff 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -708,6 +708,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings (`xpath` is not provided). `` in an XML string gives a `SyntaxError`. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. From 237ba7a483667ced37d071c3684af26e01c47d27 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 07:09:12 -0800 Subject: [PATCH 02/49] JavaScript: a limit during an XML insert, delete or put leaves the tree intact insertChild, prependChild, replace and list assignment take their heap and fuel before moving a node out of its old parent. Replace past the last child charges before the push, so a child is never left without its parent link. Deleting a child or attribute by name, assigning an attribute or assigning text to an element take the fuel for the whole step before emptying the node. normalize removes the next text node before it stores the joined text. --- crates/js/src/xml.rs | 65 ++++++++++++++++------- crates/js/src/xml_tests.rs | 103 +++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 20 deletions(-) diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 7d70fa6893..1277f54029 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -616,22 +616,22 @@ impl Interp { } fn node_delete_at(&mut self, x: ObjId, i: usize) -> JsResult<()> { - let node = self.xml_node_mut(x)?; - if i < node.children.len() { - let c = node.children.remove(i); - let len = node.children.len(); - self.xml_node_mut(c)?.parent = None; + let len = self.children_len(x)?; + if i < len { self.xml_text_fuel(len)?; + let c = self.xml_node_mut(x)?.children.remove(i); + self.xml_node_mut(c)?.parent = None; } Ok(()) } fn node_delete_name(&mut self, x: ObjId, n: &NameRef) -> JsResult<()> { if n.attribute { + let count = self.xml_node(x)?.attrs.len(); + self.xml_take_fuel(count)?; let attrs = std::mem::take(&mut self.xml_node_mut(x)?.attrs); let mut kept = Vec::with_capacity(attrs.len()); for a in attrs { - self.use_fuel()?; let hit = self.xml_node(a)?.name.as_ref().is_some_and(|q| n.matches(q)); if hit { self.xml_node_mut(a)?.parent = None; @@ -642,10 +642,11 @@ impl Interp { self.xml_node_mut(x)?.attrs = kept; return Ok(()); } + let count = self.children_len(x)?; + self.xml_take_fuel(count)?; let children = std::mem::take(&mut self.xml_node_mut(x)?.children); let mut kept = Vec::with_capacity(children.len()); for c in children { - self.use_fuel()?; if n.matches_child(self.xml_node(c)?) { self.xml_node_mut(c)?.parent = None; } else { @@ -842,15 +843,21 @@ impl Interp { Ok(if parent == x && q < at { at - 1 } else { at }) } + /// Charges for one more child of `x`. `x` and `v` are held as roots across any collection. + fn xml_charge_child(&mut self, x: ObjId, v: ObjId) -> JsResult<()> { + self.rooted(&[Value::Object(x), Value::Object(v)], |i| i.xml_charge(XML_REF_BYTES)) + } + + /// Takes the fuel and heap for the insertion first, so a limit leaves `v` where it was. fn insert_node(&mut self, x: ObjId, at: usize, v: ObjId) -> JsResult { + self.xml_charge_child(x, v)?; + let len = self.children_len(x)?; + self.xml_text_fuel(len + 1)?; let at = self.detach(x, v, at)?; - self.xml_charge(XML_REF_BYTES)?; let node = self.xml_node_mut(x)?; let at = at.min(node.children.len()); node.children.insert(at, v); - let len = node.children.len(); self.xml_node_mut(v)?.parent = Some(x); - self.xml_text_fuel(len)?; Ok(at) } @@ -883,6 +890,15 @@ impl Interp { Ok(()) } + /// Charges for a [`Self::set_child`] of `v` at `at` that adds a child. + fn charge_set_child(&mut self, x: ObjId, at: usize, v: ObjId) -> JsResult<()> { + if at < self.children_len(x)? { + return Ok(()); + } + self.xml_charge_child(x, v) + } + + /// Puts `v` at `at`, or last when `at` is past the end, after [`Self::charge_set_child`]. fn set_child(&mut self, x: ObjId, at: usize, v: ObjId) -> JsResult<()> { let node = self.xml_node_mut(x)?; let old = match node.children.get_mut(at) { @@ -892,9 +908,6 @@ impl Interp { None } }; - if old.is_none() { - self.xml_charge(XML_REF_BYTES)?; - } if let Some(old) = old { self.xml_node_mut(old)?.parent = None; } @@ -914,6 +927,7 @@ impl Interp { if self.child_at(x, at)? == Some(*v) { return Ok(()); } + self.charge_set_child(x, at, *v)?; let at = self.detach(x, *v, at)?; self.set_child(x, at, *v) } @@ -926,11 +940,13 @@ impl Interp { let s = self.xml_node(*v)?.value.clone(); let t = self.new_text_node(s, Some(x))?; self.xml_keep(t); + self.charge_set_child(x, at, t)?; self.set_child(x, at, t) } Content::Text(s) => { let t = self.new_text_node(s.clone(), Some(x))?; self.xml_keep(t); + self.charge_set_child(x, at, t)?; self.set_child(x, at, t) } } @@ -983,10 +999,11 @@ impl Interp { self.content_to_str(c)? }; let mut found = None; + let count = self.xml_node(x)?.attrs.len(); + self.xml_take_fuel(count)?; let attrs = std::mem::take(&mut self.xml_node_mut(x)?.attrs); let mut kept = Vec::with_capacity(attrs.len()); for a in attrs { - self.use_fuel()?; let hit = self.xml_node(a)?.name.as_ref().is_some_and(|q| n.matches(q)); if hit && found.is_some() { self.xml_node_mut(a)?.parent = None; @@ -1054,6 +1071,7 @@ impl Interp { })?; self.xml_keep(y); self.check_attach_node(x, y)?; + self.charge_set_child(x, at, y)?; self.set_child(x, at, y)?; } at @@ -1061,15 +1079,22 @@ impl Interp { }; if primitive { let Some(target) = self.child_at(x, at)? else { return Ok(()) }; + let s = self.content_to_str(c)?; + let count = self.children_len(target)?; + self.xml_take_fuel(count)?; + let text = if s.len() > 0 { + let t = self.new_text_node(s, Some(target))?; + self.xml_keep(t); + self.xml_charge_child(target, t)?; + Some(t) + } else { + None + }; let children = std::mem::take(&mut self.xml_node_mut(target)?.children); for child in children { - self.use_fuel()?; self.xml_node_mut(child)?.parent = None; } - let s = self.content_to_str(c)?; - if s.len() > 0 { - self.xml_replace(target, 0, &Content::Text(s))?; - } + self.xml_node_mut(target)?.children.extend(text); Ok(()) } else { self.xml_replace(x, at, c) @@ -1621,8 +1646,8 @@ impl Interp { let (p, q) = (self.xml_node(child)?.value.clone(), self.xml_node(next)?.value.clone()); self.concat(&p, &q)? }; - self.xml_node_mut(child)?.value = joined; self.node_delete_at(x, i + 1)?; + self.xml_node_mut(child)?.value = joined; } if self.xml_node(child)?.value.len() == 0 { self.node_delete_at(x, i)?; diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index e4e7a09fcb..804234bef8 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -589,6 +589,109 @@ fn limits_cannot_be_caught_by_the_script() { assert_eq!(kind_of(TIGHT, "try { for (;;) ().toXMLString(); } catch (e) { 1 }"), RunErrorKind::Limit(LimitKind::Fuel)); } +/// Collects garbage and sets the heap limit to the live size plus the argument, so a script can +/// run out of heap at a chosen charge. A negative argument puts the heap over its limit, which +/// also happens when a lowered limit meets a larger heap. +fn squeeze( + i: &mut crate::interp::Interp, + _: crate::value::Value, + args: &[crate::value::Value], + _: bool, +) -> crate::interp::JsResult { + i.collect_garbage(); + if let Some(crate::value::Value::Number(spare)) = args.first() { + i.limits.heap_bytes = (i.heap.bytes as f64 + *spare) as usize; + } + Ok(crate::value::Value::Undefined) +} + +/// Runs `setup`, then the action for each step from 0 until one completes, and after each stop +/// asserts that `check` is true with the limits lifted. Returns how many runs stopped at `kind`. +fn stops_with_the_tree_intact(setup: &str, action: impl Fn(usize) -> String, check: &str, kind: LimitKind, limit: impl Fn(usize) -> Limits) -> usize { + let mut r = realm(); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + let mut stops = 0; + for step in 0..200_000 { + r.set_limits(Limits::default()); + r.run(setup).unwrap(); + r.set_limits(limit(step)); + let action = action(step); + let got = r.run(&action); + r.set_limits(Limits::default()); + assert_eq!(r.run(check), Ok(JsValue::Bool(true)), "{action}: {got:?}"); + match got { + Ok(_) => return stops, + Err(e) if e.kind == RunErrorKind::Limit(kind) => stops += 1, + Err(_) => {} + } + } + panic!("{check} never completed"); +} + +/// Runs `action`, whose `SPARE` is replaced by a call that leaves each number of bytes free in +/// turn, from 512 too few. Actions put it after the steps whose allocations a collection could +/// free: an argument is evaluated after the callee is looked up. +fn heap_stops(setup: &str, action: &str, check: &str) -> usize { + let action = |spare: usize| action.replace("SPARE", &format!("squeeze({})", spare as i64 - 512)); + stops_with_the_tree_intact(setup, action, check, LimitKind::Heap, |_| Limits::default()) +} + +fn fuel_stops(setup: &str, action: &str, check: &str) -> usize { + stops_with_the_tree_intact(setup, |_| action.to_string(), check, LimitKind::Fuel, |fuel| Limits { fuel: fuel as u64, ..Limits::default() }) +} + +const MOVE_SETUP: &str = "var o = ; var t = ; var n = o.b[0];"; +const MOVE_CHECK: &str = + "o.b.length() + t.b.length() === 1 && (o.b.length() === 0 || o.b[0].parent() === o) && (t.b.length() === 0 || t.b[0].parent() === t)"; + +#[test] +fn a_heap_limit_during_a_move_leaves_the_node_in_its_old_parent() { + for action in + ["t.appendChild((SPARE, n))", "t.prependChild((SPARE, n))", "t.insertChildAfter(null, (SPARE, n))", "t.insertChildBefore(null, (SPARE, n))"] + { + assert!(heap_stops(MOVE_SETUP, action, MOVE_CHECK) > 0, "{action}"); + } +} + +#[test] +fn a_fuel_limit_during_a_move_leaves_the_node_in_its_old_parent() { + for action in ["t.appendChild(n)", "t.prependChild(n)"] { + assert!(fuel_stops(MOVE_SETUP, action, MOVE_CHECK) > 0, "{action}"); + } + let replaced = "var l = t.*; l[0] = o.b[0]"; + let setup = "var o = ; var t = ;"; + assert!(fuel_stops(setup, replaced, MOVE_CHECK) > 0); +} + +#[test] +fn a_heap_limit_on_replace_past_the_end_leaves_no_child_without_a_parent() { + let check = "var n = x.*.length(); if (n === 0) { true } else { \ + var v = x.*[n - 1]; var name; try { v.appendChild(x); name = 'none' } catch (e) { name = e.name } v.parent() === x && name === 'TypeError' }"; + let action = "x.replace({ toString: function () { SPARE; return '5'; } }, v)"; + assert!(heap_stops("var x = ; var v = ;", action, check) > 0); +} + +#[test] +fn limits_during_deletes_and_puts_keep_the_other_nodes() { + let setup = "var x = old;"; + let others = "x.c.length() === 1 && x.@q == '2'"; + for action in ["delete x.b", "delete x.@p", "x.@p = 'v'", "x.@n = 'v'", "x.b = 'v'"] { + assert!(fuel_stops(setup, action, others) > 0, "{action}"); + } + let held = format!("{others} && x.b.length() === 1 && (x.b == 'old' || x.b == 'v') && x.b.*[0].parent() === x.b[0]"); + assert!(fuel_stops(setup, "x.b = 'v'", &held) > 0); + assert!(heap_stops(setup, "x.b = (SPARE, 'v')", &held) > 0); +} + +#[test] +fn a_limit_during_normalize_does_not_repeat_text() { + let setup = "var x = ; x.appendChild('p'); x.appendChild('q'); x.appendChild('r');"; + let check = "x.toString() === 'pqr'"; + assert!(fuel_stops(setup, "x.normalize()", check) > 0); +} + #[test] fn an_interrupt_stops_serialization() { let mut r = realm(); From dbfd3f8a803100ecde46e849c000ee63872ee78a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 07:22:20 -0800 Subject: [PATCH 03/49] JavaScript: XML rejects reserved namespace bindings and names The XML parser now throws SyntaxError when markup binds the xml or xmlns prefix or either fixed URI against the Namespaces in XML rules. addNamespace, setName, setLocalName and setNamespace throw TypeError for the same bindings and leave the node unchanged. A new element or attribute cannot be in the xmlns namespace, and an attribute in no namespace cannot be named xmlns. The xmlns URI cannot be the default XML namespace. These assignments throw TypeError. --- crates/js/src/xml.rs | 41 ++++++++++++++++++ crates/js/src/xml_builtins.rs | 30 ++++++++++++-- crates/js/src/xml_tests.rs | 78 +++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 1277f54029..475870bebf 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -27,6 +27,8 @@ fn parser_units(s: &str) -> Vec { pub(crate) const STAR: &[u16] = &[0x2A]; const XML_URI: &str = "http://www.w3.org/XML/1998/namespace"; +const XMLNS_URI: &str = "http://www.w3.org/2000/xmlns/"; +pub(crate) const RESERVED_NS_BINDING: &str = "a namespace declaration uses a reserved prefix or URI"; /// The hidden binding that holds a function's default XML namespace URI. const DEFAULT_NS_BINDING: &[u16] = &[0, 0x64, 0x78, 0x6E, 0x73]; @@ -221,6 +223,30 @@ fn prefix_in_scope<'a>(stack: &'a [XmlNs], prefix: &[u16]) -> Option<&'a XmlNs> stack.iter().rev().find(|ns| ns.prefix.as_deref() == Some(prefix)) } +/// Whether Namespaces in XML allows declaring `prefix` for `uri`: `xml` only with its fixed URI, +/// `xmlns` never, and neither fixed URI with another prefix or as the default namespace. +pub(crate) fn ns_binding_allowed(prefix: &[u16], uri: &[u16]) -> bool { + if units_are(prefix, "xmlns") { + return false; + } + let is_xml_uri = units_are(uri, XML_URI); + if units_are(prefix, "xml") { + return is_xml_uri; + } + !is_xml_uri && !units_are(uri, XMLNS_URI) +} + +/// Whether a node named `name` can be written as XML: no name is in the `xmlns` namespace, and +/// no attribute in no namespace is named `xmlns`. +pub(crate) fn xml_name_writable(name: &XmlName, attribute: bool) -> bool { + let uri = name.uri.as_deref().unwrap_or_default(); + !units_are(uri, XMLNS_URI) && !(attribute && uri.is_empty() && units_are(&name.local, "xmlns")) +} + +fn units_are(units: &[u16], text: &str) -> bool { + units.iter().copied().eq(text.encode_utf16()) +} + /// A prefix no entry of `stack` uses. fn fresh_prefix(stack: &[XmlNs]) -> Key { let mut n = 0u32; @@ -361,6 +387,12 @@ impl Interp { /// A new node. What `node` refers to must be reachable from the roots. pub(crate) fn new_xml_node(&mut self, node: XmlNode) -> JsResult { + if let Some(name) = &node.name + && matches!(node.kind, XmlKind::Element | XmlKind::Attribute) + && !xml_name_writable(name, node.kind == XmlKind::Attribute) + { + return Err(self.type_error(RESERVED_NS_BINDING)); + } let extra = XML_NODE_BYTES + node.name.as_ref().map_or(0, name_bytes) + string_bytes(node.value.len()); self.reserve_heap(OBJECT_BYTES + extra)?; self.heap.bytes = self.heap.bytes.saturating_add(extra); @@ -432,6 +464,9 @@ impl Interp { /// Sets the default XML namespace of the running function, or of the global code. pub(crate) fn xml_set_default_uri(&mut self, uri: Key) -> JsResult<()> { + if units_are(&uri, XMLNS_URI) { + return Err(self.type_error(RESERVED_NS_BINDING)); + } let env = self.frame()?.var_env; if let Some(Env::Decl { bindings, .. }) = self.heap.env_mut(env) { let value = Value::String(Str::from_rc(uri)); @@ -2151,6 +2186,9 @@ impl Interp { for (start, end, value) in &raw_attrs { let raw = src.get(*start..*end).unwrap_or_default(); if raw == key("xmlns").as_ref() { + if !ns_binding_allowed(&[], value) { + return Err(self.syntax(RESERVED_NS_BINDING)); + } if !seen_decl.insert(Vec::new()) { return Err(self.syntax("a namespace is declared twice")); } @@ -2161,6 +2199,9 @@ impl Interp { if value.is_empty() { return Err(self.syntax("a namespace prefix cannot be declared with an empty URI")); } + if !ns_binding_allowed(local, value) { + return Err(self.syntax(RESERVED_NS_BINDING)); + } if !seen_decl.insert(local.to_vec()) { return Err(self.syntax("a namespace is declared twice")); } diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index 0076dbe94c..a5cac2912d 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -10,7 +10,9 @@ use crate::heap::{ use crate::interp::{Abrupt, Interp, JsResult}; use crate::string_builtins::{new_array, set_element}; use crate::value::{Str, Value}; -use crate::xml::{Access, Content, NameRef, STAR, Xv, canonical_index, is_ncname, is_script_xml_name}; +use crate::xml::{ + Access, Content, NameRef, RESERVED_NS_BINDING, STAR, Xv, canonical_index, is_ncname, is_script_xml_name, ns_binding_allowed, xml_name_writable, +}; const SETTINGS: [(&str, Value); 5] = [ ("ignoreComments", Value::Bool(false)), @@ -807,6 +809,9 @@ fn add_in_scope(interp: &mut Interp, x: ObjId, ns: XmlNs) -> JsResult<()> { return Ok(()); } let Some(prefix) = ns.prefix.clone() else { return Ok(()) }; + if !ns_binding_allowed(&prefix, &ns.uri) { + return Err(interp.type_error(RESERVED_NS_BINDING)); + } let node = interp.xml_node(x)?; if prefix.is_empty() && node.name.as_ref().and_then(|n| n.uri.as_ref()).is_some_and(|u| u.is_empty()) { return Ok(()); @@ -998,7 +1003,8 @@ fn checked_local(interp: &mut Interp, local: &Key) -> JsResult<()> { fn set_local_name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { let x = this_node(interp, &this)?; - if matches!(interp.xml_kind(x)?, XmlKind::Text | XmlKind::Comment) { + let kind = interp.xml_kind(x)?; + if matches!(kind, XmlKind::Text | XmlKind::Comment) { return Ok(Value::Undefined); } let v = arg(args, 0); @@ -1007,12 +1013,26 @@ fn set_local_name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> None => interp.to_str(&v)?.flat(), }; checked_local(interp, &local)?; + if kind == XmlKind::Attribute + && let Some(old) = interp.xml_node(x)?.name.clone() + { + check_name_binding(interp, &XmlName { local: local.clone(), ..old }, true)?; + } if let Some(n) = &mut interp.xml_node_mut(x)?.name { n.local = local; } Ok(Value::Undefined) } +/// Throws when an element or attribute named `name` could not be written as XML. +fn check_name_binding(interp: &mut Interp, name: &XmlName, attribute: bool) -> JsResult<()> { + let uri = name.uri.as_deref().unwrap_or_default(); + if !name.prefix.as_ref().is_none_or(|prefix| ns_binding_allowed(prefix, uri)) || !xml_name_writable(name, attribute) { + return Err(interp.type_error(RESERVED_NS_BINDING)); + } + Ok(()) +} + fn name_set_in_scope(interp: &mut Interp, x: ObjId, name: &XmlName) -> JsResult<()> { let ns = XmlNs { prefix: name.prefix.clone(), uri: name.uri.clone().unwrap_or_else(empty) }; match interp.xml_kind(x)? { @@ -1040,6 +1060,8 @@ fn set_name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResu checked_local(interp, &name.local)?; if kind == XmlKind::Pi { name.uri = Some(empty()); + } else { + check_name_binding(interp, &name, kind == XmlKind::Attribute)?; } interp.xml_node_mut(x)?.name = Some(name.clone()); name_set_in_scope(interp, x, &name)?; @@ -1048,12 +1070,14 @@ fn set_name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResu fn set_namespace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { let x = this_node(interp, &this)?; - if matches!(interp.xml_kind(x)?, XmlKind::Text | XmlKind::Comment | XmlKind::Pi) { + let kind = interp.xml_kind(x)?; + if matches!(kind, XmlKind::Text | XmlKind::Comment | XmlKind::Pi) { return Ok(Value::Undefined); } let ns = build_namespace(interp, std::slice::from_ref(&arg(args, 0)))?; let Some(old) = interp.xml_node(x)?.name.clone() else { return Ok(Value::Undefined) }; let name = XmlName { uri: Some(ns.uri), local: old.local, prefix: ns.prefix }; + check_name_binding(interp, &name, kind == XmlKind::Attribute)?; interp.xml_node_mut(x)?.name = Some(name.clone()); name_set_in_scope(interp, x, &name)?; Ok(Value::Undefined) diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 804234bef8..f6cf4d0ef1 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -360,6 +360,84 @@ fn namespaces_survive_serialization() { ); } +const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; +const XMLNS_NS: &str = "http://www.w3.org/2000/xmlns/"; + +#[test] +fn markup_cannot_declare_a_reserved_namespace_binding() { + let declarations = [ + "xmlns:xml='wrong'".to_string(), + "xmlns:xmlns='u'".to_string(), + format!("xmlns:xmlns='{XMLNS_NS}'"), + format!("xmlns:p='{XML_NS}'"), + format!("xmlns:p='{XMLNS_NS}'"), + format!("xmlns='{XML_NS}'"), + format!("xmlns='{XMLNS_NS}'"), + ]; + for declaration in declarations { + assert_eq!(thrown_name(&format!("")), "SyntaxError", "{declaration}"); + assert_eq!(thrown_name(&format!("new XML(\"\")")), "SyntaxError", "{declaration}"); + } + assert_eq!(string(&format!("().toXMLString()")), format!("")); + assert_eq!(string("().toXMLString()"), ""); +} + +#[test] +fn add_namespace_rejects_a_reserved_binding_and_keeps_the_element() { + for (prefix, uri) in [("xmlns", "u"), ("xmlns", XMLNS_NS), ("xml", "u"), ("p", XML_NS), ("p", XMLNS_NS)] { + let src = + format!("var x = ; var name = 'none'; try {{ x.addNamespace(new Namespace('{prefix}', '{uri}')); }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{prefix} {uri}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{prefix} {uri}"); + } + assert_eq!( + string(&format!("var x = ; x.addNamespace(new Namespace('xml', '{XML_NS}')); x.toXMLString()")), + format!("") + ); +} + +#[test] +fn names_cannot_write_a_reserved_namespace_binding() { + for setter in [ + "setNamespace(new Namespace('xmlns', 'u'))", + "setName(new QName(new Namespace('xml', 'u'), 'b'))", + "setNamespace(new Namespace('p', 'http://www.w3.org/2000/xmlns/'))", + "setNamespace('http://www.w3.org/2000/xmlns/')", + ] { + let src = format!("var x = ; var name = 'none'; try {{ x.{setter}; }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{setter}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{setter}"); + } + let q = |prefix: &str| format!("new QName(new Namespace('{prefix}', 'u'), 'b')"); + assert_eq!(string(&format!("var x = ; x[{}] = '1'; x.toXMLString()", q("xmlns"))), "\n 1\n"); + assert_eq!(string(&format!("var x = ; x.@[{}] = '1'; x.toXMLString()", q("xml"))), ""); +} + +#[test] +fn an_attribute_cannot_be_renamed_xmlns() { + for setter in ["setName('xmlns')", "setLocalName('xmlns')"] { + let src = format!("var x = ; var name = 'none'; try {{ x.@b[0].{setter}; }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{setter}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{setter}"); + } +} + +#[test] +fn new_nodes_cannot_take_a_reserved_name() { + let q = format!("new QName('{XMLNS_NS}', 'b')"); + for update in ["x.@xmlns = 'u'".to_string(), format!("x[{q}] = '1'"), format!("x.@[{q}] = '1'"), format!("x[{q}][0] = '1'")] { + let src = format!("var x = ; var name = 'none'; try {{ {update}; }} catch (e) {{ name = e.name; }}"); + assert_eq!(string(&format!("{src} name")), "TypeError", "{update}"); + assert_eq!(string(&format!("{src} x.toXMLString()")), "", "{update}"); + } +} + +#[test] +fn the_xmlns_uri_cannot_be_the_default_namespace() { + assert_eq!(thrown_name(&format!("default xml namespace = '{XMLNS_NS}'")), "TypeError"); + assert_eq!(thrown_name(&format!("setDefaultXMLNamespace('{XMLNS_NS}')")), "TypeError"); +} + #[test] fn the_garbage_collector_keeps_trees_and_lists_alive() { let mut r = realm(); From 80ef5d5f53a0f9e8037e33fd36c98f04c370ea18 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 07:37:24 -0800 Subject: [PATCH 04/49] JavaScript: a limit while a list or named match replaces XML children leaves the tree intact Replacing a child with a list, inserting a list, and assigning or replacing by name now check the depth and take all fuel and heap before the first change. A limit no longer leaves the old child removed, part of a list inserted or one of several matches gone. appendChild and assignment past the end of a list remove their empty placeholder when the assignment stops. A list that takes an earlier sibling at an index keeps that node in its slot. --- crates/js/src/xml.rs | 319 ++++++++++++++++++++++------------ crates/js/src/xml_builtins.rs | 18 +- crates/js/src/xml_tests.rs | 94 ++++++++++ 3 files changed, 309 insertions(+), 122 deletions(-) diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 475870bebf..6cfe962a1c 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -654,7 +654,30 @@ impl Interp { let len = self.children_len(x)?; if i < len { self.xml_text_fuel(len)?; - let c = self.xml_node_mut(x)?.children.remove(i); + self.remove_child(x, i)?; + } + Ok(()) + } + + /// Takes the child at `i` out of `x`. The caller has checked `i` and taken the fuel. + fn remove_child(&mut self, x: ObjId, i: usize) -> JsResult<()> { + let c = self.xml_node_mut(x)?.children.remove(i); + self.xml_node_mut(c)?.parent = None; + Ok(()) + } + + /// Takes `gone`, children of `x` in document order, out of `x` in one pass. The caller has + /// taken the fuel. + fn remove_children(&mut self, x: ObjId, gone: &[ObjId]) -> JsResult<()> { + let mut next = gone.iter().peekable(); + self.xml_node_mut(x)?.children.retain(|c| { + let hit = next.peek() == Some(&c); + if hit { + next.next(); + } + !hit + }); + for &c in gone { self.xml_node_mut(c)?.parent = None; } Ok(()) @@ -867,17 +890,43 @@ impl Interp { } /// Removes `v` from its parent, so a node is never in two lists of children. Returns the - /// position in `x` to use after the removal. + /// position in `x` to use after the removal. The caller has taken [`Self::place_fuel`]. fn detach(&mut self, x: ObjId, v: ObjId, at: usize) -> JsResult { let Some(parent) = self.xml_node(v)?.parent else { return Ok(at) }; if self.xml_kind(v)? == XmlKind::Attribute { return Ok(at); } - let Some(q) = self.index_in_parent(parent, v)? else { return Ok(at) }; - self.node_delete_at(parent, q)?; + let Some(q) = self.xml_node(parent)?.children.iter().position(|&c| c == v) else { return Ok(at) }; + self.remove_child(parent, q)?; Ok(if parent == x && q < at { at - 1 } else { at }) } + /// Takes the fuel to put `v` among `len` children: the insertion and, when `v` has a parent, + /// the scan and removal that take it out. + fn place_fuel(&mut self, len: usize, v: ObjId) -> JsResult<()> { + self.xml_text_fuel(len + 1)?; + let node = self.xml_node(v)?; + if node.kind != XmlKind::Attribute + && let Some(parent) = node.parent + { + let old = self.children_len(parent)?; + self.xml_text_fuel(old)?; + self.xml_text_fuel(old)?; + } + Ok(()) + } + + /// Moves `v` to position `at` of `x` and returns where it went. The caller has taken the fuel + /// and heap. + fn place_child(&mut self, x: ObjId, at: usize, v: ObjId) -> JsResult { + let at = self.detach(x, v, at)?; + let node = self.xml_node_mut(x)?; + let at = at.min(node.children.len()); + node.children.insert(at, v); + self.xml_node_mut(v)?.parent = Some(x); + Ok(at) + } + /// Charges for one more child of `x`. `x` and `v` are held as roots across any collection. fn xml_charge_child(&mut self, x: ObjId, v: ObjId) -> JsResult<()> { self.rooted(&[Value::Object(x), Value::Object(v)], |i| i.xml_charge(XML_REF_BYTES)) @@ -887,13 +936,30 @@ impl Interp { fn insert_node(&mut self, x: ObjId, at: usize, v: ObjId) -> JsResult { self.xml_charge_child(x, v)?; let len = self.children_len(x)?; - self.xml_text_fuel(len + 1)?; - let at = self.detach(x, v, at)?; - let node = self.xml_node_mut(x)?; - let at = at.min(node.children.len()); - node.children.insert(at, v); - self.xml_node_mut(v)?.parent = Some(x); - Ok(at) + self.place_fuel(len, v)?; + self.place_child(x, at, v) + } + + /// Takes the fuel and heap to put every item of `l` under `x` and returns the items. The tree + /// does not change here, so a limit leaves it as it was. + fn reserve_list_insert(&mut self, x: ObjId, l: ObjId) -> JsResult> { + let items = self.xml_list(l)?.items.clone(); + let bytes = XML_REF_BYTES.saturating_mul(items.len()); + self.rooted(&[Value::Object(x), Value::Object(l)], |i| i.xml_charge(bytes))?; + let len = self.children_len(x)?; + for (j, &item) in items.iter().enumerate() { + self.place_fuel(len + j, item)?; + } + Ok(items) + } + + /// Puts `items` at `at` in order, after [`Self::reserve_list_insert`]. + fn place_children(&mut self, x: ObjId, at: usize, items: &[ObjId]) -> JsResult<()> { + let mut at = at; + for &item in items { + at = self.place_child(x, at, item)? + 1; + } + Ok(()) } /// [[Insert]] (ECMA-357 section 9.1.1.11). @@ -915,11 +981,8 @@ impl Interp { self.insert_node(x, at, *v)?; } Content::List(l) => { - let mut at = at; - for j in 0..self.list_len(*l)? { - let Some(item) = self.list_at(*l, j)? else { break }; - at = self.insert_node(x, at, item)? + 1; - } + let items = self.reserve_list_insert(x, *l)?; + self.place_children(x, at, &items)?; } } Ok(()) @@ -963,13 +1026,20 @@ impl Interp { return Ok(()); } self.charge_set_child(x, at, *v)?; + let len = self.children_len(x)?; + self.place_fuel(len, *v)?; let at = self.detach(x, *v, at)?; self.set_child(x, at, *v) } - Content::List(_) => { + Content::List(l) => { self.check_attach(x, c)?; - self.node_delete_at(x, at)?; - self.xml_insert(x, at, c) + let items = self.reserve_list_insert(x, *l)?; + let len = self.children_len(x)?; + self.xml_text_fuel(len)?; + if at < len { + self.remove_child(x, at)?; + } + self.place_children(x, at, &items) } Content::Node(v) => { let s = self.xml_node(*v)?.value.clone(); @@ -987,6 +1057,21 @@ impl Interp { } } + /// Replaces the first of `matches`, children of `x` in document order, with `c` and removes + /// the rest; appends `c` when there are none. A limit leaves `x` as it was. The value in `c` + /// is never one of `matches`. + pub(crate) fn xml_replace_matches(&mut self, x: ObjId, matches: &[ObjId], c: &Content) -> JsResult<()> { + let len = self.children_len(x)?; + self.xml_text_fuel(len)?; + self.xml_text_fuel(len)?; + let at = match matches.first() { + Some(first) => self.xml_node(x)?.children.iter().position(|c| c == first).unwrap_or(len), + None => len, + }; + self.xml_replace(x, at, c)?; + self.remove_children(x, matches.get(1..).unwrap_or_default()) + } + // ---- [[Put]] ---- /// A value to assign into a tree, copied when it is XML (steps 3 and 4 of section 9.1.1.2). @@ -1074,66 +1159,68 @@ impl Interp { if !is_ncname(&n.local) && !n.is_star() { return Ok(()); } - let primitive = matches!(c, Content::Text(_)) && !n.is_star(); - let mut found: Option = None; - for k in (0..self.children_len(x)?).rev() { - self.use_fuel()?; - let Some(child) = self.child_at(x, k)? else { continue }; + let len = self.children_len(x)?; + self.xml_take_fuel(len)?; + let mut matches = Vec::new(); + for k in 0..len { + let Some(child) = self.child_at(x, k)? else { break }; if n.matches_child(self.xml_node(child)?) { - if let Some(i) = found { - self.node_delete_at(x, i)?; - } - found = Some(k); + matches.push(child); } } - let at = match found { - Some(i) => i, + match c { + Content::Text(s) if !n.is_star() => self.put_element_text(x, n, &matches, s), + _ => self.xml_replace_matches(x, &matches, c), + } + } + + /// Makes `s` the only content of the first of `matches`, or of a new element named `n` when + /// there are none, and removes the other matches. A limit leaves `x` as it was. + fn put_element_text(&mut self, x: ObjId, n: &NameRef, matches: &[ObjId], s: &Str) -> JsResult<()> { + let at = self.children_len(x)?; + self.xml_text_fuel(at)?; + let target = match matches.first() { + Some(&first) => first, None => { - let at = self.children_len(x)?; - if primitive { - let uri = match &n.uri { - Some(u) => u.clone(), - None => self.xml_default_uri()?, - }; - let y = self.new_xml_node(XmlNode { - kind: XmlKind::Element, - name: Some(XmlName { uri: Some(uri), local: n.local.clone(), prefix: None }), - parent: Some(x), - value: Str::from(""), - attrs: Vec::new(), - children: Vec::new(), - ns: Vec::new(), - })?; - self.xml_keep(y); - self.check_attach_node(x, y)?; - self.charge_set_child(x, at, y)?; - self.set_child(x, at, y)?; - } - at + let uri = match &n.uri { + Some(u) => u.clone(), + None => self.xml_default_uri()?, + }; + let y = self.new_xml_node(XmlNode { + kind: XmlKind::Element, + name: Some(XmlName { uri: Some(uri), local: n.local.clone(), prefix: None }), + parent: Some(x), + value: Str::from(""), + attrs: Vec::new(), + children: Vec::new(), + ns: Vec::new(), + })?; + self.xml_keep(y); + self.check_attach_node(x, y)?; + self.charge_set_child(x, at, y)?; + y } }; - if primitive { - let Some(target) = self.child_at(x, at)? else { return Ok(()) }; - let s = self.content_to_str(c)?; - let count = self.children_len(target)?; - self.xml_take_fuel(count)?; - let text = if s.len() > 0 { - let t = self.new_text_node(s, Some(target))?; - self.xml_keep(t); - self.xml_charge_child(target, t)?; - Some(t) - } else { - None - }; - let children = std::mem::take(&mut self.xml_node_mut(target)?.children); - for child in children { - self.xml_node_mut(child)?.parent = None; - } - self.xml_node_mut(target)?.children.extend(text); - Ok(()) + let count = self.children_len(target)?; + self.xml_take_fuel(count)?; + let text = if s.len() > 0 { + let t = self.new_text_node(s.clone(), Some(target))?; + self.xml_keep(t); + self.xml_charge_child(target, t)?; + Some(t) } else { - self.xml_replace(x, at, c) + None + }; + if matches.is_empty() { + self.set_child(x, at, target)?; } + self.remove_children(x, matches.get(1..).unwrap_or_default())?; + let children = std::mem::take(&mut self.xml_node_mut(target)?.children); + for child in children { + self.xml_node_mut(child)?.parent = None; + } + self.xml_node_mut(target)?.children.extend(text); + Ok(()) } fn node_put(&mut self, x: ObjId, n: &NameRef, v: &Value) -> JsResult<()> { @@ -1210,7 +1297,6 @@ impl Interp { } fn list_put_index(&mut self, x: ObjId, index: usize, v: &Value) -> JsResult<()> { - let mut i = index; let data = self.xml_list(x)?; let target = data.target; let target_name = data.target_name.clone(); @@ -1221,7 +1307,7 @@ impl Interp { }, None => None, }; - if i >= self.list_len(x)? { + if index >= self.list_len(x)? { let r = match r { Some(r) => match self.classify(&Value::Object(r)) { Xv::List(rl) => { @@ -1262,29 +1348,56 @@ impl Interp { let y = self.new_xml_node(XmlNode { kind, name, parent: r, value: Str::from(""), attrs: Vec::new(), children: Vec::new(), ns: Vec::new() })?; self.xml_keep(y); - i = self.list_len(x)?; - if kind == XmlKind::Attribute { - if let Some(r) = r { - self.xml_charge(XML_REF_BYTES)?; - self.xml_node_mut(r)?.attrs.push(y); - } - } else if let Some(r) = r { - let len = self.children_len(r)?; - let at = if i > 0 { - let prev = self.list_at(x, i - 1)?; - let position = match prev { - Some(prev) => self.index_in_parent(r, prev)?, - None => None, - }; - position.map_or(len, |j| j + 1) - } else { - len - }; - self.check_attach_node(r, y)?; - self.insert_node(r, at, y)?; + let i = self.list_len(x)?; + let put = self.list_append_new(x, i, r, kind, y).and_then(|()| self.list_put_at(x, i, v)); + if put.is_err() { + self.undo_list_append(x, y)?; } - self.list_push(x, y)?; + return put; } + self.list_put_at(x, index, v) + } + + /// Adds the new node `y` as item `i` of `x` and, when there is a parent `r`, to the tree. + fn list_append_new(&mut self, x: ObjId, i: usize, r: Option, kind: XmlKind, y: ObjId) -> JsResult<()> { + if kind == XmlKind::Attribute { + if let Some(r) = r { + self.xml_charge(XML_REF_BYTES)?; + self.xml_node_mut(r)?.attrs.push(y); + } + } else if let Some(r) = r { + let len = self.children_len(r)?; + let at = if i > 0 { + let prev = self.list_at(x, i - 1)?; + let position = match prev { + Some(prev) => self.index_in_parent(r, prev)?, + None => None, + }; + position.map_or(len, |j| j + 1) + } else { + len + }; + self.check_attach_node(r, y)?; + self.insert_node(r, at, y)?; + } + self.list_push(x, y) + } + + /// Takes `y` out of its parent and of `x` after an assignment that added it stopped. It takes + /// no fuel or heap, so it runs after a limit. + fn undo_list_append(&mut self, x: ObjId, y: ObjId) -> JsResult<()> { + if let Some(parent) = self.xml_node(y)?.parent { + let node = self.xml_node_mut(parent)?; + node.children.retain(|&c| c != y); + node.attrs.retain(|&a| a != y); + self.xml_node_mut(y)?.parent = None; + } + self.xml_list_mut(x)?.items.retain(|&item| item != y); + Ok(()) + } + + /// Assigns `v` to item `i` of `x`. + fn list_put_at(&mut self, x: ObjId, i: usize, v: &Value) -> JsResult<()> { let Some(item) = self.list_at(x, i)? else { return Ok(()) }; let xv = self.classify(v); let value = match xv { @@ -1313,21 +1426,13 @@ impl Interp { Content::List(l) => { let c = self.list_shallow_copy(l)?; self.xml_keep(c); - let count = self.list_len(c)?; + let items = self.xml_list(c)?.items.clone(); + self.xml_charge(XML_REF_BYTES.saturating_mul(items.len()))?; if let Some(parent) = parent && let Some(q) = self.index_in_parent(parent, item)? { self.xml_replace(parent, q, &Content::List(c))?; - for j in 0..count { - if let Some(child) = self.child_at(parent, q + j)? - && let Some(slot) = self.xml_list_mut(c)?.items.get_mut(j) - { - *slot = child; - } - } } - let items = self.xml_list(c)?.items.clone(); - self.xml_charge(XML_REF_BYTES.saturating_mul(items.len()))?; self.xml_list_mut(x)?.items.splice(i..=i, items); Ok(()) } @@ -1348,14 +1453,14 @@ impl Interp { replaced = self.child_at(parent, q)?; } let new_item = match (replaced, value) { - (Some(r), _) => r, - (None, Content::Node(n)) => *n, + (_, Content::Node(n)) => *n, + (Some(r), Content::Text(_)) => r, (None, Content::Text(s)) => { let t = self.new_text_node(s.clone(), None)?; self.xml_keep(t); t } - (None, Content::List(_)) => return Ok(()), + (_, Content::List(_)) => return Ok(()), }; if let Some(slot) = self.xml_list_mut(x)?.items.get_mut(i) { *slot = new_item; diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index a5cac2912d..1662dc5dbd 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -967,23 +967,11 @@ fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul }; let matched = i.xml_select(x, &NameRef { attribute: false, ..n })?; i.xml_keep(matched); - let count = i.list_len(matched)?; - if count == 0 { + let items = i.xml_list(matched)?.items.clone(); + if items.is_empty() { return Ok(()); } - let Some(first) = i.list_at(matched, 0)? else { return Ok(()) }; - for at in (1..count).rev() { - let Some(item) = i.list_at(matched, at)? else { continue }; - i.use_fuel()?; - let position = i.xml_node(x)?.children.iter().position(|&c| c == item); - if let Some(q) = position { - let removed = i.xml_node_mut(x)?.children.remove(q); - i.xml_node_mut(removed)?.parent = None; - } - } - i.use_fuel()?; - let at = i.xml_node(x)?.children.iter().position(|&c| c == first).unwrap_or(0); - i.xml_replace(x, at, &c) + i.xml_replace_matches(x, &items, &c) })?; Ok(this) } diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index f6cf4d0ef1..86b813d251 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -763,6 +763,100 @@ fn limits_during_deletes_and_puts_keep_the_other_nodes() { assert!(heap_stops(setup, "x.b = (SPARE, 'v')", &held) > 0); } +/// True when the markup of `tree` is `before` or `after`, ignoring white space. +fn tree_is(tree: &str, before: &str, after: &str) -> String { + format!("(function () {{ var s = {tree}.toXMLString().replace(/\\s/g, ''); return s === '{before}' || s === '{after}'; }})()") +} + +#[test] +fn a_limit_while_a_list_replaces_a_child_leaves_the_children() { + let setup = "var x = ; var v = <>

;"; + let replaced = tree_is("x", "", "

"); + for action in ["x.replace(0, v)", "x.b = v"] { + assert!(fuel_stops(setup, action, &replaced) > 0, "{action}"); + } + let spare = "x.replace({ toString: function () { SPARE; return '0'; } }, v)"; + assert!(heap_stops(setup, spare, &replaced) > 0); + let set = tree_is("x", "", "

"); + assert!(fuel_stops(setup, "x.setChildren(v)", &set) > 0); +} + +#[test] +fn a_limit_while_a_list_is_inserted_leaves_the_children() { + let setup = "var o =

; var x = ; var v = o.*;"; + let check = |after: &str| { + format!("{} && (o.*.length() === 0 ? x.*.length() === 3 : o.*.length() === 2 && x.*.length() === 1)", tree_is("x", "", after)) + }; + for (action, after) in [ + ("x.appendChild(v)", "

"), + ("x.prependChild(v)", "

"), + ("x.insertChildAfter(null, v)", "

"), + ("x.insertChildBefore(null, v)", "

"), + ] { + assert!(fuel_stops(setup, action, &check(after)) > 0, "{action}"); + } + assert!(heap_stops(setup, "x.appendChild((SPARE, v))", &check("

")) > 0); +} + +#[test] +fn a_heap_limit_on_a_list_put_at_an_index_keeps_the_list_and_tree_in_step() { + let setup = "var x = ; var l = x.*; var v = <>

;"; + let check = "var s = x.toXMLString().replace(/\\s/g, ''); \ + (s === '' && l.length() === 2 && l[0].name() == 'b') \ + || (s === '

' && l.length() === 3 && l[0].name() == 'p')"; + assert!(heap_stops(setup, "l[(SPARE, 0)] = v", check) > 0); +} + +#[test] +fn an_index_put_of_an_earlier_sibling_keeps_that_node_in_the_list() { + let setup = "var x = ; var l = x.*;"; + truth(&format!("{setup} l[2] = x.b; l[2] === x.b[0] && l[3] === x.e[0]")); + truth(&format!("{setup} l[2] = x.b[0]; l[2] === x.b[0] && l[3] === x.e[0]")); +} + +/// A realm whose trees may be 32 levels deep, and a script line making `deep`, a tree of 32 levels +/// that cannot go under any element. +fn shallow_with_deep_value() -> (Realm, &'static str) { + let deep = "var s = ''; var e = ''; for (var i = 0; i < 32; i++) { s += ''; e += ''; } var deep = new XML(s + e);"; + (Realm::with_options(Options { max_depth: 32, ..e4x() }, Limits::default()), deep) +} + +#[test] +fn a_too_deep_value_leaves_every_match_in_place() { + for action in ["x.replace('b', deep)", "x.b = deep"] { + let (mut r, deep) = shallow_with_deep_value(); + r.run(deep).unwrap(); + r.run("var x = ;").unwrap(); + assert_eq!(r.run(action).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Heap)), "{action}"); + assert_eq!(r.run("x.b.length() === 2 && x.*.length() === 3"), Ok(JsValue::Bool(true)), "{action}"); + } +} + +#[test] +fn a_limit_while_matches_are_replaced_keeps_every_match() { + let setup = "var x = ; var n = ; var l = <>

;"; + let two = ""; + for (action, after) in [ + ("x.replace('b', n)", ""), + ("x.replace('b', l)", "

"), + ("x.b = n", ""), + ("x.b = l", "

"), + ("x.b = 'v'", "v"), + ] { + assert!(fuel_stops(setup, action, &tree_is("x", two, after)) > 0, "{action}"); + } + let by_name = "x.replace({ toString: function () { SPARE; return 'b'; } }, n)"; + assert!(heap_stops(setup, by_name, &tree_is("x", two, "")) > 0); + assert!(heap_stops(setup, "x.b = (SPARE, 'v')", &tree_is("x", two, "v")) > 0); +} + +#[test] +fn matches_are_replaced_in_document_order() { + truth("var x = 123; x.replace('b', ); x.*.length() === 3 && x.*[0].name() == 'n' && x.d.length() === 1"); + truth("var x = 12; var old = x.b[1]; x.b = 'v'; x.b.length() === 1 && x.b == 'v' && old.parent() === null"); + truth("var x = ; x.b = 'v'; x.b == 'v' && x.*.length() === 2 && x.b[0].parent() === x"); +} + #[test] fn a_limit_during_normalize_does_not_repeat_text() { let setup = "var x = ; x.appendChild('p'); x.appendChild('q'); x.appendChild('r');"; From 01673695afb3e9d4e9cc3d1999548168f4617e38 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 07:47:29 -0800 Subject: [PATCH 05/49] JavaScript: a computed XML name or attribute run cannot add markup An embedded expression used as a tag or attribute name must now be one XML name, and one used as a run of attributes must hold only name="value" attributes separated by white space. Anything else throws SyntaxError, so a script string cannot add an attribute, close the tag or add a child. {name}={value} in a tag now reads as a computed attribute name, as in the example in ECMA-357 section 11.1.4. --- crates/js/src/ast.rs | 6 ++- crates/js/src/bytecode.rs | 2 +- crates/js/src/compiler.rs | 5 ++- crates/js/src/parser/xml.rs | 38 ++++++++++--------- crates/js/src/xml.rs | 74 ++++++++++++++++++++++++++++++------- crates/js/src/xml_tests.rs | 74 +++++++++++++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 35 deletions(-) diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs index ca991645b1..5a4363108a 100644 --- a/crates/js/src/ast.rs +++ b/crates/js/src/ast.rs @@ -210,8 +210,10 @@ pub enum XmlPart { Content(ExprId), /// `{expression}` as an attribute's value. Attribute(ExprId), - /// `{expression}` as a name or a run of attributes, its string used as markup. - Raw(ExprId), + /// `{expression}` as an element or attribute name, its string checked as an XML name. + Name(ExprId), + /// `{expression}` as a run of attributes, its string checked as `name="value"` attributes. + Attributes(ExprId), } /// What stands before `::` in a qualified name. diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 895d911716..ed99aa95d2 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -153,7 +153,7 @@ pub(crate) enum Op { /// `object name` → the descendants that match. XmlDescendants, /// `value` → the string an embedded expression puts in a literal (0 content, 1 attribute - /// value, 2 name). + /// value, 2 name, 3 run of attributes). XmlEscape(u8), /// The `parts` strings → the XML or XMLList they spell. XmlLiteral { diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index f0f171d59c..9c07113cce 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -861,12 +861,13 @@ impl<'a> FnCompiler<'a> { let i = self.string(units); self.emit(Op::String(i)); } - XmlPart::Content(e) | XmlPart::Attribute(e) | XmlPart::Raw(e) => { + XmlPart::Content(e) | XmlPart::Attribute(e) | XmlPart::Name(e) | XmlPart::Attributes(e) => { self.expr(*e)?; self.emit(Op::XmlEscape(match part { XmlPart::Content(_) => 0, XmlPart::Attribute(_) => 1, - _ => 2, + XmlPart::Name(_) => 2, + _ => 3, })); } } diff --git a/crates/js/src/parser/xml.rs b/crates/js/src/parser/xml.rs index 0b29bf8790..e91691a58e 100644 --- a/crates/js/src/parser/xml.rs +++ b/crates/js/src/parser/xml.rs @@ -8,7 +8,7 @@ use super::{Parser, Result, describe}; use crate::ast::{Expr, ExprId, Stmt, StmtId, XmlNamePart, XmlPart, XmlQualifier}; use crate::error::TOO_DEEP; use crate::token::{Keyword, Punct, Span, TokenKind}; -use crate::xml::{is_name_part, is_name_start, is_xml_space, scan_reference, valid_xml_units}; +use crate::xml::{is_name_start, is_xml_space, scan_name, scan_reference}; const LT: u16 = b'<' as u16; const GT: u16 = b'>' as u16; @@ -277,17 +277,7 @@ impl Parser<'_> { /// Reads a static name at `pos`, which must be one, and returns the offset after it. fn xml_static_name(&self, pos: usize) -> Result { - if !self.unit(pos).is_some_and(is_name_start) { - return Err(self.xml_error(pos, "invalid XML name")); - } - let mut at = pos + 1; - while self.unit(at).is_some_and(is_name_part) { - at += 1; - } - if !valid_xml_units(self.src.get(pos..at).unwrap_or_default()) { - return Err(self.xml_error(pos, "invalid XML name")); - } - Ok(at) + scan_name(self.src, pos).ok_or_else(|| self.xml_error(pos, "invalid XML name")) } fn xml_skip_space(&self, mut pos: usize, scan: &mut Scan) -> usize { @@ -308,7 +298,7 @@ impl Parser<'_> { let name = if self.unit(at) == Some(LBRACE) { scan.flush(); let (e, after) = self.xml_embedded(at)?; - scan.parts.push(XmlPart::Raw(e)); + scan.parts.push(XmlPart::Name(e)); at = after; None } else { @@ -338,8 +328,17 @@ impl Parser<'_> { } scan.flush(); let (e, after) = self.xml_embedded(at)?; - scan.parts.push(XmlPart::Raw(e)); - at = after; + let mut next = after; + while self.unit(next).is_some_and(is_xml_space) { + next += 1; + } + if self.unit(next) == Some(EQ) { + scan.parts.push(XmlPart::Name(e)); + at = self.xml_attribute_value(after, scan)?; + } else { + scan.parts.push(XmlPart::Attributes(e)); + at = after; + } } Some(_) if at == before => return Err(self.xml_error(at, "expected white space before an attribute")), Some(_) => at = self.xml_attribute(at, scan)?, @@ -351,7 +350,12 @@ impl Parser<'_> { fn xml_attribute(&mut self, pos: usize, scan: &mut Scan) -> Result { let end = self.xml_static_name(pos)?; self.xml_copy(pos, end, scan); - let at = self.xml_skip_space(end, scan); + self.xml_attribute_value(end, scan) + } + + /// Reads the `=` and value of an attribute whose name ends at `pos` and returns the offset after them. + fn xml_attribute_value(&mut self, pos: usize, scan: &mut Scan) -> Result { + let at = self.xml_skip_space(pos, scan); if self.unit(at) != Some(EQ) { return Err(self.xml_error(at, "expected '=' after an attribute name")); } @@ -397,7 +401,7 @@ impl Parser<'_> { let name = if self.unit(at) == Some(LBRACE) { scan.flush(); let (e, after) = self.xml_embedded(at)?; - scan.parts.push(XmlPart::Raw(e)); + scan.parts.push(XmlPart::Name(e)); at = after; None } else { diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 6cfe962a1c..5a02ccdc14 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -18,7 +18,7 @@ use crate::heap::{ XmlListData, XmlName, XmlNode, XmlNs, key, string_bytes, }; use crate::interp::{Abrupt, ErrorType, HeldBytes, Interp, JsResult}; -use crate::string_builtins::UNITS_PER_FUEL; +use crate::string_builtins::{UNITS_PER_FUEL, flat_charged}; use crate::value::{Str, Value, index_key}; fn parser_units(s: &str) -> Vec { @@ -123,6 +123,53 @@ pub(crate) fn is_name_part(c: u16) -> bool { is_name_start(c) || matches!(c, 0x2D | 0x2E | 0xB7) || (c != 0x24 && is_id_part(u32::from(c))) } +/// The offset after the XML name that starts at `src[pos]`, when one does. +pub(crate) fn scan_name(src: &[u16], pos: usize) -> Option { + if !src.get(pos).copied().is_some_and(is_name_start) { + return None; + } + let mut end = pos + 1; + while src.get(end).copied().is_some_and(is_name_part) { + end += 1; + } + valid_xml_units(src.get(pos..end).unwrap_or_default()).then_some(end) +} + +fn skip_xml_space(src: &[u16], mut pos: usize) -> usize { + while src.get(pos).copied().is_some_and(is_xml_space) { + pos += 1; + } + pos +} + +/// Whether `units` holds only `name="value"` attributes separated by white space. +fn is_attribute_run(units: &[u16]) -> bool { + let mut at = skip_xml_space(units, 0); + while at < units.len() { + let Some(name_end) = scan_name(units, at) else { return false }; + let eq = skip_xml_space(units, name_end); + if units.get(eq) != Some(&EQ) { + return false; + } + let open = skip_xml_space(units, eq + 1); + let quote = match units.get(open).copied() { + Some(q @ (DQUOTE | SQUOTE)) => q, + _ => return false, + }; + let value = units.get(open + 1..).unwrap_or_default(); + let Some(len) = value.iter().position(|&c| c == quote || c == LT) else { return false }; + if value.get(len) != Some("e) { + return false; + } + let end = open + len + 2; + at = skip_xml_space(units, end); + if at == end && at < units.len() { + return false; + } + } + true +} + /// An XML name without a prefix (ECMA-357 section 13.1.2.1). pub(crate) fn is_ncname(units: &[u16]) -> bool { let Some((&first, rest)) = units.split_first() else { return false }; @@ -2145,17 +2192,7 @@ impl Interp { } fn read_name(&mut self, src: &[u16], pos: usize) -> JsResult { - if !src.get(pos).copied().is_some_and(is_name_start) { - return Err(self.syntax("invalid XML name")); - } - let mut end = pos + 1; - while src.get(end).copied().is_some_and(is_name_part) { - end += 1; - } - if !valid_xml_units(src.get(pos..end).unwrap_or_default()) { - return Err(self.syntax("invalid XML name")); - } - Ok(end) + scan_name(src, pos).ok_or_else(|| self.syntax("invalid XML name")) } fn check_units(&mut self, src: &[u16], from: usize, to: usize) -> JsResult<()> { @@ -2605,7 +2642,18 @@ impl Interp { let Value::String(s) = out.finish(self) else { return Err(Abrupt::Internal("not a string".into())) }; s } - _ => self.to_str(&v)?, + _ => { + let text = self.to_str(&v)?; + let units = flat_charged(self, &text)?; + self.xml_take_fuel(units.len() / UNITS_PER_FUEL + 1)?; + if kind == 2 && scan_name(&units, 0) != Some(units.len()) { + return Err(self.syntax("an embedded expression used as an XML name does not give a name")); + } + if kind != 2 && !is_attribute_run(&units) { + return Err(self.syntax("an embedded expression used as XML attributes does not give only attributes")); + } + text + } }; self.replace(1, Value::String(result)) } diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 86b813d251..58c984ad4f 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -120,6 +120,60 @@ fn embedded_expressions_are_escaped_by_position() { truth("({1 < 2}) == 'true'"); } +#[test] +fn a_computed_tag_name_cannot_add_markup() { + for src in [ + r#"(<{'a x="1"'}/>)"#, + "(<{'a>)", + "(t)", + "(<{''}/>)", + "(<{'1a'}/>)", + "()", + r#"()"#, + ] { + assert_eq!(thrown_name(src), "SyntaxError", "{src}"); + } +} + +#[test] +fn a_computed_name_may_be_any_xml_name() { + assert_eq!(string("(<{'a.b-c'}/>).toXMLString()"), ""); + assert_eq!(string("(<{'tag'}>t).toXMLString()"), "t"); + assert_eq!(string("var n = 'n:b'; (<{n}/>).toXMLString()"), "\n \n"); +} + +#[test] +fn a_computed_attribute_name_matches_the_standard_example() { + let vars = "var tagname = 'name', attributename = 'id', attributevalue = 5, content = 'Fred';"; + let literal = "<{tagname} {attributename}={attributevalue}>{content}"; + assert_eq!(string(&format!("{vars} ({literal}).toXMLString()")), "Fred"); + assert_eq!(string(r#"var a = 'id'; ().toXMLString()"#), ""); +} + +#[test] +fn a_computed_attribute_run_cannot_add_markup() { + for src in [ + r#"()"#, + r#"(<>)"#, + "()", + "()", + r#"()"#, + r#"()"#, + ] { + assert_eq!(thrown_name(src), "SyntaxError", "{src}"); + } +} + +#[test] +fn a_computed_attribute_run_may_hold_any_attributes() { + truth( + r#"var a = " a=\"1\"\n b = '>' "; var x = ; x.@a == '1' && x.@b == '>' && x.attributes().length() === 2 && x.children().length() === 0"#, + ); + assert_eq!(string("().toXMLString()"), ""); + assert_eq!(string("().toXMLString()"), ""); +} + // ---- selectors and updates ---- #[test] @@ -953,9 +1007,29 @@ fn tree_markup(shape: &[u8]) -> String { out } +const TAG_PIECES: [&str; 11] = ["a", "b", " ", "=", "\"", "'", "<", "/", "<", "a=\"1\"", ">; v.children().length() === 0 && v.attributes().length() === 0"), + format!("var v = ; v.children().length() === 0 && v.name().localName === 'x'"), + format!("(<>).length() === 1"), + format!("var v = ; v.children().length() === 1"), + ] { + let mut r = limited(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + match r.run(&src) { + Ok(v) => prop_assert_eq!(v, JsValue::Bool(true), "{}", src), + Err(e) => prop_assert!(e.kind != RunErrorKind::Internal, "{src:?}: {e:?}"), + } + } + } + #[test] fn source_mixes_parse_or_give_a_located_error(parts in proptest::collection::vec(0..PIECES.len(), 0..40)) { let src: String = parts.iter().map(|&i| PIECES[i]).collect(); From 33f04522f8742d068e310e6c02eef9f8bfe598e0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 07:58:49 -0800 Subject: [PATCH 06/49] JavaScript: an attribute placed among XML children goes in as text ECMA-357 sends a single attribute given to [[Insert]] through [[Replace]], which makes a text node. A list holding an attribute went into the children as the attribute itself, changed its parent and stayed on its old owner. Each attribute now goes in as a new text node with its value, and it stays on its owner. A list assigned such a value holds the new text nodes, and an attribute does not count against the depth limit. --- crates/js/src/xml.rs | 79 ++++++++++++++++++++++++++------------ crates/js/src/xml_tests.rs | 44 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 5a02ccdc14..c84bd6a6db 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -907,7 +907,9 @@ impl Interp { Content::List(l) => { for at in 0..self.list_len(*l)? { let Some(item) = self.list_at(*l, at)? else { break }; - self.check_attach_node(x, item)?; + if self.xml_kind(item)? != XmlKind::Attribute { + self.check_attach_node(x, item)?; + } } Ok(()) } @@ -940,9 +942,6 @@ impl Interp { /// position in `x` to use after the removal. The caller has taken [`Self::place_fuel`]. fn detach(&mut self, x: ObjId, v: ObjId, at: usize) -> JsResult { let Some(parent) = self.xml_node(v)?.parent else { return Ok(at) }; - if self.xml_kind(v)? == XmlKind::Attribute { - return Ok(at); - } let Some(q) = self.xml_node(parent)?.children.iter().position(|&c| c == v) else { return Ok(at) }; self.remove_child(parent, q)?; Ok(if parent == x && q < at { at - 1 } else { at }) @@ -952,10 +951,7 @@ impl Interp { /// the scan and removal that take it out. fn place_fuel(&mut self, len: usize, v: ObjId) -> JsResult<()> { self.xml_text_fuel(len + 1)?; - let node = self.xml_node(v)?; - if node.kind != XmlKind::Attribute - && let Some(parent) = node.parent - { + if let Some(parent) = self.xml_node(v)?.parent { let old = self.children_len(parent)?; self.xml_text_fuel(old)?; self.xml_text_fuel(old)?; @@ -987,15 +983,30 @@ impl Interp { self.place_child(x, at, v) } - /// Takes the fuel and heap to put every item of `l` under `x` and returns the items. The tree - /// does not change here, so a limit leaves it as it was. + /// The node to place as a child for `item`: `item` itself, or a new text node holding the + /// value when it is an attribute. `roots` are held across any collection. + fn child_for_item(&mut self, item: ObjId, roots: &[Value]) -> JsResult { + if self.xml_kind(item)? != XmlKind::Attribute { + return Ok(item); + } + let value = self.xml_node(item)?.value.clone(); + let t = self.rooted(roots, |i| i.new_text_node(value, None))?; + self.xml_keep(t); + Ok(t) + } + + /// Takes the fuel and heap to put every item of `l` under `x` and returns the nodes to place, + /// from [`Self::child_for_item`]. The tree does not change here, so a limit leaves it as it + /// was. fn reserve_list_insert(&mut self, x: ObjId, l: ObjId) -> JsResult> { - let items = self.xml_list(l)?.items.clone(); + let mut items = self.xml_list(l)?.items.clone(); let bytes = XML_REF_BYTES.saturating_mul(items.len()); - self.rooted(&[Value::Object(x), Value::Object(l)], |i| i.xml_charge(bytes))?; + let roots = [Value::Object(x), Value::Object(l)]; + self.rooted(&roots, |i| i.xml_charge(bytes))?; let len = self.children_len(x)?; - for (j, &item) in items.iter().enumerate() { - self.place_fuel(len + j, item)?; + for (j, item) in items.iter_mut().enumerate() { + *item = self.child_for_item(*item, &roots)?; + self.place_fuel(len + j, *item)?; } Ok(items) } @@ -1009,6 +1020,17 @@ impl Interp { Ok(()) } + /// Puts a new text node holding `s` at position `at` of `x`. + fn insert_text(&mut self, x: ObjId, at: usize, s: &Str) -> JsResult<()> { + let t = self.new_text_node(s.clone(), Some(x))?; + self.xml_keep(t); + self.xml_charge(XML_REF_BYTES)?; + let node = self.xml_node_mut(x)?; + let at = at.min(node.children.len()); + node.children.insert(at, t); + Ok(()) + } + /// [[Insert]] (ECMA-357 section 9.1.1.11). pub(crate) fn xml_insert(&mut self, x: ObjId, at: usize, c: &Content) -> JsResult<()> { if self.xml_kind(x)? != XmlKind::Element { @@ -1016,13 +1038,10 @@ impl Interp { } self.check_attach(x, c)?; match c { - Content::Text(s) => { - let t = self.new_text_node(s.clone(), Some(x))?; - self.xml_keep(t); - self.xml_charge(XML_REF_BYTES)?; - let node = self.xml_node_mut(x)?; - let at = at.min(node.children.len()); - node.children.insert(at, t); + Content::Text(s) => self.insert_text(x, at, s)?, + Content::Node(v) if self.xml_kind(*v)? == XmlKind::Attribute => { + let value = self.xml_node(*v)?.value.clone(); + self.insert_text(x, at, &value)?; } Content::Node(v) => { self.insert_node(x, at, *v)?; @@ -1473,11 +1492,21 @@ impl Interp { Content::List(l) => { let c = self.list_shallow_copy(l)?; self.xml_keep(c); - let items = self.xml_list(c)?.items.clone(); + let place = match parent { + Some(parent) => self.index_in_parent(parent, item)?.map(|q| (parent, q)), + None => None, + }; + let mut items = self.xml_list(c)?.items.clone(); + if place.is_some() { + let roots = [Value::Object(x), Value::Object(c)]; + for item in &mut items { + self.use_fuel()?; + *item = self.child_for_item(*item, &roots)?; + } + self.xml_list_mut(c)?.items.clone_from(&items); + } self.xml_charge(XML_REF_BYTES.saturating_mul(items.len()))?; - if let Some(parent) = parent - && let Some(q) = self.index_in_parent(parent, item)? - { + if let Some((parent, q)) = place { self.xml_replace(parent, q, &Content::List(c))?; } self.xml_list_mut(x)?.items.splice(i..=i, items); diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 58c984ad4f..40d66fd645 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -911,6 +911,50 @@ fn matches_are_replaced_in_document_order() { truth("var x = ; x.b = 'v'; x.b == 'v' && x.*.length() === 2 && x.b[0].parent() === x"); } +#[test] +fn an_attribute_put_among_children_goes_in_as_text_and_stays_on_its_owner() { + let setup = "var y = ; var x = ;"; + let check = "x.b.length() === 0 && x.children().length() === 1 && x.children()[0].nodeKind() === 'text' && x.toString() === 'v' \ + && y.@p.length() === 1 && y.@p.parent() === y && y.@p[0].parent() === y"; + for edit in ["x.replace(0, y.@p)", "x.replace(0, y.@p[0])", "x.b = y.@p", "x.setChildren(y.@p)", "x.*[0] = y.@p"] { + truth(&format!("{setup} {edit}; {check}")); + } + let owner = "y.@p.length() === 1 && y.@p.parent() === y && y.@p[0].parent() === y"; + for (edit, k) in [ + ("x.prependChild(y.@p)", "0"), + ("x.prependChild(y.@p[0])", "0"), + ("x.appendChild(y.@p)", "1"), + ("x.appendChild(y.@p[0])", "1"), + ("x.insertChildAfter(x.b[0], y.@p)", "1"), + ("x.insertChildBefore(x.b[0], y.@p[0])", "0"), + ] { + truth(&format!( + "{setup} {edit}; x.children().length() === 2 && x.children()[{k}].nodeKind() === 'text' && x.children()[{k}] == 'v' && {owner}" + )); + } +} + +#[test] +fn a_list_assigned_attributes_holds_the_text_nodes_in_the_tree() { + truth( + "var y = ; var x = ; var l = x.*; l[0] = y.@*; \ + l.length() === 2 && l[0].nodeKind() === 'text' && l[0].parent() === x && l[1].nodeKind() === 'text' && l[1].parent() === x \ + && x.toString() === 'vw' && y.@*.length() === 2", + ); +} + +#[test] +fn an_attribute_list_goes_in_as_text_at_the_depth_limit() { + let open = "".repeat(7); + let close = "".repeat(7); + let src = format!( + "var x = new XML('{open}{close}'); var c = x; while (c.a.length()) c = c.a[0]; \ + var y = ; c.prependChild(y.@p); c.toString() === 'v'" + ); + let mut realm = Realm::with_options(Options { max_depth: 8, ..e4x() }, Limits::default()); + assert_eq!(realm.run(&src), Ok(JsValue::Bool(true))); +} + #[test] fn a_limit_during_normalize_does_not_repeat_text() { let setup = "var x = ; x.appendChild('p'); x.appendChild('q'); x.appendChild('r');"; From 01cd72d7c7439f39bc008776e861c8113d53598f Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 11 Nov 2026 14:38:53 +0400 Subject: [PATCH 07/49] i18n(ka): update the Georgian catalog --- crates/ui-egui/src/i18n/ka.tsv | 106 ++------------------------------- 1 file changed, 4 insertions(+), 102 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 82f646ac8b..8be7e25840 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -522,7 +522,6 @@ Show Guides დამხმარე ხაზების ჩვენება Lock Guides დამხმარე ხაზების ჩაკეტვა Lock Guides by Default დამხმარე ხაზების ნაგულისხმევად ჩაკეტვა - Artboard Guides with Cmd/Ctrl + Ruler Drag სამუშაო დაფის დამხმარე ხაზები Cmd/Ctrl + სახაზავიდან გადათრევით Unlock Guides დამხმარე ხაზების განბლოკვა Make Guides დამხმარე ხაზების შექმნა Release Guides დამხმარე ხაზების მოხსნა @@ -663,20 +662,6 @@ Other Library… სხვა ბიბლიოთეკა… Graphic Style Libraries გრაფიკული სტილების ბიბლიოთეკები Shadows and Glows ჩრდილები და ნათებები - Game HUD თამაშის HUD - Health Fill სიცოცხლე - Mana Glow მანა - Damage Flash ზიანი - Quest Outline ქვესტი - Coin Shine მონეტა - Boss Alert ბოსი - Status Effects სტატუსის ეფექტები - Poison Tint შხამი - Freeze Edge გაყინვა - Burn Aura დაწვა - Stun Pulse დაბნეულობა - Stealth Fade შენიღბვა - Shield Buff ფარი Outlines and Rules კონტურები და ხაზები Hand-Drawn ხელით დახატული Gradient Finishes გრადიენტული ზედაპირები @@ -2002,8 +1987,6 @@ digits Malayalam მალაიალამური Green მწვანე Shifts the tints of global and spot colors; other colors stay. ცვლის გლობალური და სპოტ-ფერების ტონირებებს; სხვა ფერები უცვლელი რჩება. Tint ტონირება - Fill Tint შევსების ტონირება - Stroke Tint კონტურის ტონირება Color Guide Options ფერის გზამკვლევის პარამეტრები Less ნაკლები Steps: ბიჯები: @@ -2352,7 +2335,6 @@ digits Malayalam მალაიალამური File Dimensions ფაილის ზომები Transforms გარდაქმნები Background Color: ფონის ფერი: - Guide Label: დამხმარე ხაზების იარლიყი: Show Center Mark ცენტრის ნიშნის ჩვენება Show Cross Hairs ჯვარედინი ხაზების ჩვენება Show Video Safe Areas ვიდეოს უსაფრთხო არეების ჩვენება @@ -3318,12 +3300,6 @@ theme Appearance იერსახე Real-time Drawing and Editing რეალურ დროში ხატვა და რედაქტირება Render Threads (-1 = Automatic) ნაკადები (-1 = ავტომატური) Reset Preferences პარამეტრების ჩამოყრა - Export Preferences… პარამეტრების ექსპორტირება… - Import Preferences… პარამეტრების იმპორტირება… - Save the applied preferences to a JSON file გამოყენებული პარამეტრების შენახვა JSON ფაილში - Apply the preferences in a file Export Preferences saved პარამეტრების გამოყენება ფაილიდან, რომელიც „პარამეტრების ექსპორტირებამ“ შეინახა - Export Preferences პარამეტრების ექსპორტირება - Import Preferences პარამეტრების იმპორტირება Search Preferences პარამეტრებში ძიება No settings found პარამეტრები ვერ მოიძებნა Scale Corners კუთხეების მასშტაბირება @@ -3665,8 +3641,6 @@ font-technology Variable ცვლადი Preferences პარამეტრები Previous წინა Restore every preference to its default (applied on OK) ყველა პარამეტრის ნაგულისხმევ მნიშვნელობაზე დაბრუნება (გამოიყენება OK-ზე დაჭერისას) - Reset all preferences to their defaults? დავაბრუნოთ ყველა პარამეტრი ნაგულისხმევ მნიშვნელობებზე? - The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. ნაგულისხმევი მნიშვნელობები გამოიყენება, როცა პარამეტრებში OK-ს დააჭერთ. იქ გაუქმება ინარჩუნებს თქვენს მიმდინარე პარამეტრებს. Smaller ↔ Larger უფრო პატარა ↔ უფრო დიდი Smart Guides (View → Smart Guides) ჭკვიანი დამხმარე ხაზები (ხედი → ჭკვიანი დამხმარე ხაზები) Couldn't save recovery data: {e} აღდგენის მონაცემები ვერ შეინახა: {e} @@ -4052,7 +4026,6 @@ font-technology Variable ცვლადი @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis უნდა იყოს "vertical", "horizontal" ან კუთხე გრადუსებში და არა `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" ფონი {other}: transparent, white, black ან ფერი, მაგალითად "#ff8800" @msg bad color {v} არასწორი ფერი {v} -@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` არის null, ფენის ფერის სახელი ან #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio უნდა იყოს რიცხვი 0,1-დან 10-მდე @msg bad colour `{hex}` (#rrggbb) არასწორი ფერი `{hex}` (#rrggbb) @msg bad page tree გვერდების არასწორი ხე @@ -4107,11 +4080,6 @@ font-technology Variable ცვლადი @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) შიდა შეცდომა `{cmd}`-ში: {msg} (დოკუმენტი წინანდელ მდგომარეობაში დარჩა; გთხოვთ, შეგვატყობინოთ ამ ხარვეზის შესახებ) @msg internal error: {msg} (please report this bug) შიდა შეცდომა: {msg} (გთხოვთ, შეგვატყობინოთ ამ ხარვეზის შესახებ) @msg invalid PDF setting: {0} PDF-ის არასწორი პარამეტრი: {0} -@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): ის იწერება UserUnit {unit}-ით, ამიტომ აპები, რომლებიც UserUnit-ს უგულებელყოფენ, მას ამდენჯერ პატარას აჩვენებენ -@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): {label} გვერდები იწერება UserUnit-ის გარეშე, პუნქტებში, და აპები, რომლებიც ლიმიტს იცავენ, გვერდს 200 in-ზე ჭრიან -@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5-ს არ აქვს UserUnit, რომელიც 14 400 pt-ზე (200 in) გრძელ გვერდს ზომის შესანარჩუნებლად სჭირდება: ფაილი იწერება როგორც PDF 1.6 -@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units სამუშაო დაფა {n}-ის გვერდი არის {size}, ძალიან გრძელი და ვიწრო PDF-ის 3-დან 14 400 ერთეულამდე გვერდის მხარეებისთვის: ის იწერება UserUnit {unit}-ით, რომელიც მოკლე მხარეს 3 ერთეულზე ან მეტზე ინარჩუნებს, ხოლო ზღვრის დამცველი აპები გრძელ მხარეს 14 400 ერთეულამდე ჭრიან -@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} ფაილები ინახავს ნამდვილ რიცხვებს {max}-მდე, სამუშაო დაფა {n}-ის {side} pt სიგრძის გვერდს კი უფრო დიდი სჭირდება: აირჩიეთ PDF 1.6 ან უფრო ახალი PDF/X სტანდარტის გარეშე, ან უფრო პატარა სამუშაო დაფა @msg invalid image data for `{0}` არასწორი გამოსახულების მონაცემები `{0}`-სთვის @msg invalid parameters for `{cmd}`: {msg} `{cmd}`: არასწორი პარამეტრები — {msg} @msg is not an image VectorCraft reads ეს არ არის გამოსახულება, რომელსაც VectorCraft კითხულობს @@ -4425,20 +4393,6 @@ font-technology Variable ცვლადი @msg unknown overprints `{o}` (preserve, simulate or discard) უცნობი ზედდაბეჭდვა `{o}` (preserve, simulate ან discard) @msg unknown panel `{raw}` უცნობი პანელი `{raw}` @msg unknown preference `{key}` უცნობი პარამეტრი `{key}` -@msg Preferences imported. პარამეტრები იმპორტირებულია. -@msg not a preferences file: {e} ეს არ არის პარამეტრების ფაილი: {e} -@msg not a preferences file: no JSON object ეს არ არის პარამეტრების ფაილი: JSON ობიექტი არ არის -@msg not a preferences file (format `{format}`) ეს არ არის პარამეტრების ფაილი (ფორმატი `{format}`) -@msg not a preferences file: no `format` ეს არ არის პარამეტრების ფაილი: `format` არ არის -@msg not a preferences file: no `version` ეს არ არის პარამეტრების ფაილი: `version` არ არის -@msg not a preferences file: no `prefs` ეს არ არის პარამეტრების ფაილი: `prefs` არ არის -@msg the file was saved by a newer version of VectorCraft (version {v}) ფაილი შენახულია VectorCraft-ის უფრო ახალი ვერსიით (ვერსია {v}) -@msg the file holds no preferences ფაილში პარამეტრები არ არის -@msg `{key}` names a folder, which preferences files leave out `{key}` საქაღალდეს მიუთითებს, პარამეტრების ფაილები კი საქაღალდეებს არ შეიცავს -@msg the file is larger than 1 MB ფაილი 1 MB-ზე დიდია -@msg give `path` or `data` მიუთითეთ `path` ან `data` -@msg `{key}` must be an object `{key}` უნდა იყოს ობიექტი -@msg `prefs.import` runs on its own, not as a batch step `prefs.import` ცალკე სრულდება და არა პაკეტის ნაბიჯად @msg unknown preset (one of {_1}) უცნობი პრესეტი (ერთ-ერთი: {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) უცნობი პრესეტი `{name}` (high, medium, low ან შენახული: იხ. flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) უცნობი პრესეტი `{name}` (იხ. flattener.presets.list) @@ -5005,8 +4959,7 @@ font-technology Variable ცვლადი Bevel both sides წაკვეთა ორივე მხარეს Presets პრესეტები @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. ობიექტს ერთი 3D ეფექტი შეიძლება ჰქონდეს. შეცვალეთ არსებული ეფექტის პარამეტრები ან ჯერ წაშალეთ ის. -@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი, ჯგუფი, გადასვლა ან სიმბოლოს ეგზემპლარი. ჯერ გაშალეთ სხვა ობიექტები. -@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: გამოსახულებიდან მყარი სხეულის აგება შეუძლებელია. ჯერ მოახდინეთ მისი ვექტორიზაცია ბრძანებით ობიექტი › გამოსახულების ვექტორიზაცია › შექმნა. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი ან ჯგუფი. ჯერ გარდაქმენით გამოსახულებები და სხვა ობიექტები ტრაექტორიებად. @msg {label} requires a shape with at least two anchors {label}: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით @msg The shape is too complex for a 3D effect ფიგურა ზედმეტად რთულია 3D ეფექტისთვის @msg The 3D surface is too complex; simplify the shape 3D ზედაპირი ზედმეტად რთულია; გაამარტივეთ ფიგურა @@ -5025,7 +4978,6 @@ file-filter Swatch libraries ფერის ნიმუშების ბი file-filter Flattener presets გაბრტყელების პრესეტები file-filter PDF presets PDF-ის პრესეტები file-filter Print presets ბეჭდვის პრესეტები -file-filter Preferences პარამეტრები file-filter Export presets ექსპორტის პრესეტები file-filter SVG Compressed შეკუმშული SVG file-filter PDF-compatible .ai PDF-თან თავსებადი .ai @@ -5186,7 +5138,6 @@ imageTrace Outline კონტური Stop Script სკრიპტის შეჩერება Show External Script Warning გარე სკრიპტის გაფრთხილების ჩვენება A Script Is Running სკრიპტი მუშაობს - Script Alert სკრიპტის შეტყობინება A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. სკრიპტს შეუძლია ღია დოკუმენტების შეცვლა და მის მიერ გაშვებული ბრძანებებით ფაილების ჩაწერა. გაუშვით მხოლოდ სანდო სკრიპტები. Do not show this warning again აღარ აჩვენო ეს გაფრთხილება Stop the script and undo what it did? Then quit again. შეჩერდეს სკრიპტი და გაუქმდეს მისი ცვლილებები? შემდეგ ხელახლა გამოდით. @@ -5200,11 +5151,8 @@ imageTrace Outline კონტური @msg the script is too large to run სკრიპტი გასაშვებად ძალიან დიდია @msg a drag or an undo group is open; finish it before running a script ღიაა გადათრევა ან გაუქმების ჯგუფი; დაასრულეთ სკრიპტის გაშვებამდე @msg the script left a document that cannot be kept სკრიპტმა დატოვა დოკუმენტი, რომლის შენახვა შეუძლებელია -@msg the script is no longer in the Scripts folder სკრიპტი აღარ არის Scripts საქაღალდეში - Some scripts were not listed ზოგიერთი სკრიპტი არ არის ჩამოთვლილი - The Scripts folder cannot be read Scripts საქაღალდის წაკითხვა შეუძლებელია @msg the script's run is no longer available სკრიპტის გაშვება აღარ არის ხელმისაწვდომი -@msg a failed command changed undo steps that could not be put back, so the script was stopped წარუმატებელმა ბრძანებამ შეცვალა გაუქმების ნაბიჯები, რომელთა აღდგენა ვერ მოხერხდა, ამიტომ სკრიპტი შეჩერდა +@msg a failed command changed undo steps that could not be put back, so the script was stopped წარუმატებელმა ბრძანებამ დაბრუნების ნაბიჯები შეცვალა და მათი პირვანდელ მდგომარეობაში მოყვანა ვერ მოხერხდა, ამიტომ სკრიპტი შეჩერდა @msg a recovery copy could not be written, so the script was not run აღდგენის ასლის ჩაწერა ვერ მოხერხდა, ამიტომ სკრიპტი არ გაეშვა @msg Running a script სკრიპტი მუშაობს @msg Copied the script report კოპირებულია სკრიპტის ანგარიში @@ -5326,7 +5274,6 @@ imageTrace Outline კონტური Preview (1 inch on the page) წინასწარი ნახვა (1 დუიმი გვერდზე) Set as Default ნაგულისხმევად დაყენება out from a highlighted edge to place its dimension | — გამოკვეთილი კიდიდან გარეთ, მისი ზომის დასასმელად | - two points, then move and click to place an aligned, horizontal or vertical dimension | — ორ წერტილზე, შემდეგ გადაადგილეთ და დააწკაპუნეთ გასწორებული, ჰორიზონტალური ან ვერტიკალური ზომის დასასმელად | cancels | Double-click the tool for options — გაუქმება | პარამეტრებისთვის ორჯერ დაწკაპეთ ხელსაწყოზე Angular კუთხური Away from the Line ხაზისგან მოშორებით @@ -5356,17 +5303,14 @@ imageTrace Outline კონტური @msg Invalid drawing scale ნახაზის არასწორი მასშტაბი @msg Invalid real scale რეალური ზომის არასწორი მასშტაბი @msg Measured anchor no longer exists გაზომილი საყრდენი წერტილი აღარ არსებობს -@msg Invalid measured point გაზომილი წერტილი არასწორია @msg Measured segment no longer exists გაზომილი სეგმენტი აღარ არსებობს @msg Non-finite dimension ზომა სასრული რიცხვი არ არის -@msg Only linear dimensions are horizontal or vertical მხოლოდ ხაზოვანი ზომებია ჰორიზონტალური ან ვერტიკალური @msg Scale Text Attributes: selected type has no scale to bake ტექსტის ატრიბუტების მასშტაბირება: არჩეულ ტექსტს არ აქვს გადასაყვანი მასშტაბი @msg Scale must be drawing:real, for example 1:10 მასშტაბი უნდა იყოს ნახაზი:რეალური, მაგალითად 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 მასშტაბის შეფარდება 1:1000000-სა და 1000000:1-ს შორის უნდა იყოს @msg Second measured segment no longer exists მეორე გაზომილი სეგმენტი აღარ არსებობს @msg Unknown dimension mode ზომის უცნობი რეჟიმი @msg Unknown dimension option {k} ზომის უცნობი პარამეტრი {k} -@msg Unknown dimension orientation ზომის უცნობი ორიენტაცია @msg Unknown dimension style ზომის უცნობი სტილი @msg Unknown unit უცნობი ერთეული Close the circular ends of open profiles. Partial sweeps keep their cut sides open. ღია პროფილების წრიული ბოლოების დახურვა. ნაწილობრივი ბრუნვისას ჭრის მხარეები ღია რჩება. @@ -5722,7 +5666,7 @@ imageTrace Outline კონტური @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. ბრტყელი ლოგოს რეჟიმი keepColors-ს არ იყენებს: ის ვექტორიზებს logoColors-ში მითითებულ ფერებს ან მის მიერ ნაპოვნ ფერებს. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). არაფერი ვექტორიზდა: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება keepColors-ს ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. არაფერი ვექტორიზდა: ფერის იგნორირება გამოტოვებს keepColors-ში ჩამოთვლილი ფერების ყველა არეს. -@msg Nothing was traced: the colors that match keepColors produce no paths. არაფერი ვექტორიზდა: keepColors-ის შესაბამისი ფერებიდან არცერთი კონტური არ მიიღება. +@msg Nothing was traced: the colors that match keepColors produce no paths. არაფერი ვექტორიზდა: keepColors-ის შესაბამისი ფერებიდან არცერთი ტრაექტორია არ მიიღება. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება {c}-ს, ამიტომ მისგან არაფერი ვექტორიზდება. @msg `colorGroup` needs `library: "document"` `colorGroup`-ს სჭირდება `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup`-ს სჭირდება `palette: "documentLibrary"` @@ -5942,20 +5886,6 @@ axis Y Axis Y ღერძი Show its progress, see Cancel and write log lines პროგრესის ჩვენება, გაუქმების შემჩნევა და ჟურნალის სტრიქონების ჩაწერა Keep settings of its own საკუთარი პარამეტრების შენახვა Keep a cache of its own, which you can clear საკუთარი ქეშის შენახვა, რომლის გასუფთავებაც შეგიძლიათ - Run these commands on the document: {ids} ამ ბრძანებების გაშვება დოკუმენტზე: {ids} -@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call „{name}“ `commandAllowlist`-ში ასახელებს `{id}`-ს, რომლის გამოძახებაც პლაგინს არ შეუძლია -@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} -@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: მანიფესტის `commandAllowlist` მას არ ასახელებს -@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID ან არგუმენტები მოდულის მეხსიერების გარეთაა -@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID ან არგუმენტები UTF-8 არ არის -@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: არგუმენტები JSON ობიექტი არ არის -@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist`-ის გამოცხადება მხოლოდ ჩვეულებრივი ბიუჯეტის ფილტრს შეუძლია -@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) ბრძანებების გამოძახებები ({MAX_CALLS} გამოძახება და {MAX_CALL_BYTES} ბაიტი ერთ გაშვებაზე) -@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) ბრძანების გამოძახების ზომა (vc_call `{shown}`: {len} ბაიტი, ლიმიტი {MAX_CALL_DATA}) -@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` მაქსიმუმ {MAX_ALLOWED_COMMANDS} ბრძანება `commandAllowlist`-ში -@msg a plug-in may not call this command პლაგინს ამ ბრძანების გამოძახება არ შეუძლია -@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist`-ს სჭირდება ნებართვა `commands`, ხოლო `commands`-ს სჭირდება `commandAllowlist` -@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` ასახელებს `{shown}`-ს, რომელიც ბრძანების ID არ არის ან ორჯერაა ჩამოთვლილი Clear Plug-in Cache პლაგინის ქეშის გასუფთავება @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `{permission}` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} მოდულს შემოაქვს `env.{name}` ჰოსტისგან განსხვავებული ტიპით: ჰოსტის ტიპია {signature} @@ -6050,12 +5980,6 @@ axis Y Axis Y ღერძი Flag დროშა Crosshair სამიზნე Checkpoint საკონტროლო წერტილი - Inventory Icons ინვენტარის ხატულები - Sword მახვილი - Shield ფარი - Potion წამალი - Key გასაღები - Gem ძვირფასი ქვა Open Symbol Library სიმბოლოების ბიბლიოთეკის გახსნა Save Symbol Library სიმბოლოების ბიბლიოთეკის შენახვა Save Symbol Library… სიმბოლოების ბიბლიოთეკის შენახვა… @@ -6110,11 +6034,6 @@ axis Y Axis Y ღერძი User Brush Library 9 მომხმარებლის ფუნჯების ბიბლიოთეკა 9 User Brush Library 10 მომხმარებლის ფუნჯების ბიბლიოთეკა 10 Art Ribbons ნახატის ლენტები - Zigzag ზიგზაგი - Burst Ray დარტყმის სხივი - Dash Trail წყვეტილი კვალი - Speed Line სიჩქარის ხაზი - Game Trails თამაშის კვალი Calligraphic Nibs კალიგრაფიული წვერები Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები @@ -6841,21 +6760,4 @@ font-script Japanese იაპონური Ignore All ყველას იგნორირება Add to Dictionary ლექსიკონში დამატება No Suggestions შემოთავაზებები არ არის - Edit Custom Dictionary მომხმარებლის ლექსიკონის რედაქტირება - Entry: ჩანაწერი: -# Script Console - Script Console სკრიპტების კონსოლი - Clear Script Console სკრიპტების კონსოლის გასუფთავება - Copy Script Console სკრიპტების კონსოლის კოპირება - Copy All ყველაფრის კოპირება - Copy Selected მონიშნულის კოპირება - Finished დასრულდა - Ended with an error დასრულდა შეცდომით - Out of fuel ინსტრუქციების ლიმიტი ამოიწურა - Ended at a host limit დასრულდა ჰოსტის ლიმიტზე - Ended at a limit დასრულდა ლიმიტზე - Stopped შეჩერდა - Ended by an internal fault დასრულდა შიდა შეცდომის გამო - {ms} ms, {calls} host calls {ms} ms, ჰოსტის {calls} გამოძახება - {dropped} earlier lines dropped, {cut} lines cut {dropped} ძველი სტრიქონი წაიშალა, {cut} სტრიქონი შემოკლდა -@msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ + Copies per Sheet (as many as fit, in a grid) ასლები ფურცელზე (რამდენიც დაეტევა, ბადედ) From 112fe53d88a05a448f632c92bfe7e8d1b64a208f Mon Sep 17 00:00:00 2001 From: jmverges Date: Wed, 11 Nov 2026 10:26:00 +0000 Subject: [PATCH 08/49] .ai import: an envelope in Edit Contents opens on its drawn art when its mesh lies away by a move (#6570) While an envelope's contents are edited, the editing data keeps its mesh in the envelope's dictionary. In some users' files that mesh lies away from the drawn art by a plain move, the contents sitting with the drawn art, as if the mesh hadn't followed the envelope when it moved. The envelope then drew its contents elsewhere, failed the drawn-art check and kept its drawn art. Nothing in the dictionary records the move, and no envelope saved outside Edit Contents was found displaced. A mesh or top object saved in Edit Contents (and a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again after every other way, with one more of the import's checks, its mesh moved by as far as the drawn art's box lies from the box of what it draws. The drawn-art check, the budget and the page comparison decide as before, so an envelope that opened live before opens the same way. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/envelope.rs | 67 +++++++++++++++++++-- crates/eps/src/import/ai/read/containers.rs | 13 +++- crates/eps/src/import/ai/tests.rs | 57 ++++++++++++++++++ crates/eps/src/import/tests_native.rs | 61 +++++++++++++++++++ docs/file-format-parity.md | 3 +- docs/target-app-parity.md | 4 +- 6 files changed, 195 insertions(+), 10 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 25701650c9..8b3fceecf3 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -19,10 +19,12 @@ //! (`AIDeformArtWidthKey`, `AIDeformArtHeightKey`), the contents (`X=` … `X+`, then //! `(AIDeformArtKey)`) and the kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object). Then the //! distorted art on plain lines, and the plugin's `XP`. While the contents are edited, the group -//! holds the contents and the dictionary the mesh. A top object's outline is the edge of its -//! one-patch mesh. A warp's mesh is a grid of its shape: a warp of type whose own warp doesn't come -//! out as drawn opens as a mesh envelope on that grid where that does. An envelope whose parts don't -//! read, or that doesn't come out as drawn, keeps its drawn art. +//! holds the contents and the dictionary the mesh, which in some files lies away from the drawn art +//! by a move, as if it hadn't followed the envelope: such a mesh or top object whose grid draws its +//! contents as large as the drawn art but elsewhere is tried again moved onto that art. A top +//! object's outline is the edge of its one-patch mesh. A warp's mesh is a grid of its shape: a warp +//! of type whose own warp doesn't come out as drawn opens as a mesh envelope on that grid where that +//! does. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its drawn art. use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; @@ -62,6 +64,10 @@ const MAX_GRID: u32 = 50; /// How far, in points over width and height together, a saved size may lie from the contents' /// bounds with their strokes and still count as that box. const SAVED_SIZE_SLACK: f64 = 0.5; +/// How far, in points over width and height together, what an envelope draws may differ in size +/// from its drawn art and still count as that art moved, and how far it must lie from it to count +/// as moved at all ([`moved_onto`]). +const MOVED_SLACK: f64 = 0.5; /// The bits of the options word that are read: Distort Appearance, Distort Pattern Fills, Distort /// Linear Gradients, Anti-Alias (set: off) and Preserve Shape Using (set: Clipping Mask). const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17; @@ -183,6 +189,40 @@ fn with_strokes(kind: &NodeKind) -> Option { Some(kind) } +/// The kind of `envelope`, a mesh or a top object in Edit Contents, with its grid moved onto +/// `drawn`, the drawn art the file saves for it, where what it draws is as large as that art (within +/// [`MOVED_SLACK`]) but lies elsewhere (further than that): moved by as far as the drawn art's box +/// lies from the box of what it draws. While the contents are edited, the grid is kept in the +/// envelope's dictionary, and in some files it lies away from the drawn art by a move, the contents +/// with the drawn art, as if the grid hadn't followed the envelope when it moved. `None` outside +/// Edit Contents, for a warp (it maps from its contents' box, which lies with them), and where +/// nothing is drawn. Symbol instances and pattern fills in the contents are measured as they are, +/// not drawn out: where that makes the sizes differ, no move is tried. +pub(super) fn moved_onto(envelope: &Node, drawn: &Node) -> Option { + let NodeKind::Envelope { kind, editing: true, .. } = &envelope.kind else { return None }; + if matches!(**kind, EnvelopeKind::Warp { .. }) { + return None; + } + // What it draws, measured as its drawn art is: the pieces in a group with its own appearance. + let mut drew = envelope.clone(); + drew.kind = NodeKind::Group { children: vectorcraft_render::expand_live(envelope).into_iter().map(Arc::new).collect(), clip: false }; + let (from, to) = (drew.visual_bounds()?, drawn.visual_bounds()?); + let by = to.center() - from.center(); + let resized = (from.width() - to.width()).abs() + (from.height() - to.height()).abs(); + if !(resized <= MOVED_SLACK && by.x.abs() + by.y.abs() > MOVED_SLACK && by.x.is_finite() && by.y.is_finite()) { + return None; + } + let mut moved = envelope.kind.clone(); + if let NodeKind::Envelope { kind, .. } = &mut moved { + match &mut **kind { + EnvelopeKind::Mesh { points, .. } => points.iter_mut().for_each(|p| *p += by), + EnvelopeKind::TopObject { path, .. } => path.transform(Affine::translate(by)), + EnvelopeKind::Warp { .. } => return None, + } + } + Some(moved) +} + /// A warp: its style, bend, distortions and orientation. fn warp(o: &Obj) -> Option { let style = whole(o, "DeformStyle")??; @@ -552,7 +592,8 @@ impl WarpedType { /// for it ([`live_on_saved_grid`]), which opens in its place where it does: its type stays /// editable in that shape, and the warp's style and bend are lost. With Distort Appearance, each /// is also checked mapping from its type's glyphs grown by half their strokes' weight - /// ([`with_strokes`]). Each + /// ([`with_strokes`]). In Edit Contents, a mesh or top object whose grid draws the type + /// elsewhere than its drawn art is checked last moved onto that art ([`moved_onto`]). Each /// check uses one of the import's checks and, for an envelope that shows, adds to what the page /// check allows ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't /// available, the drawn art and the hidden type stay. @@ -634,7 +675,17 @@ impl WarpedType { (plain.visible, plain.opacity) = (true, 1.0); let page = self.page.filter(|_| shown.contains(&drawn)); let mut alike = None; - for (i, kind) in kinds.into_iter().enumerate() { + // In Edit Contents, a grid that draws the type elsewhere than its drawn art is tried again + // moved onto that art ([`moved_onto`]), after every other way. + let (mut tries, mut moved, mut again, mut i) = (kinds.into_iter(), vec![], false, 0); + loop { + let Some(kind) = tries.next() else { + if again || moved.is_empty() { + break; + } + (tries, again) = (std::mem::take(&mut moved).into_iter(), true); + continue; + }; // A second try is only made while more checks are left than there are envelopes of // type and warped text objects still to come (some of which may need none): one // envelope's tries don't use up the checks of the others. @@ -642,6 +693,7 @@ impl WarpedType { return Err(TypeKept::Budget); } self.checks -= 1; + i += 1; let mut envelope = art.clone(); (envelope.id, envelope.kind) = (id, kind); let shared = Shared { patterns: &doc.patterns, images: &doc.images }; @@ -651,6 +703,9 @@ impl WarpedType { alike = Some(envelope); break; } + if !again && let Some(kind) = moved_onto(&live_one, &plain) { + moved.push(kind); + } } let envelope = alike.ok_or(TypeKept::Differs)?; if doc.node(drawn).is_none() { diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 02f6f8097c..426416b754 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -913,7 +913,18 @@ impl<'a> Reader<'a> { let mut plain = drawn.clone(); (plain.visible, plain.opacity) = (true, 1.0); let page = self.page_in_doc(); - if !crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent) { + let mut alike = crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent); + // In Edit Contents, a grid that draws the contents elsewhere than the drawn art is checked + // again moved onto that art. + if !alike + && self.envelope_checks > 0 + && let Some(kind) = crate::import::ai::envelope::moved_onto(&shown, &plain) + { + self.envelope_checks -= 1; + (envelope.kind, shown.kind) = (kind.clone(), kind); + alike = crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent); + } + if !alike { self.warn(crate::import::ai::envelope::DRAWN); return; } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 7b208caf38..4f996e7841 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7880,6 +7880,63 @@ fn an_envelope_whose_contents_are_edited_opens_live_in_edit_contents() { assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); } +/// While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files it +/// lies away from the drawn art by a move, the contents with the drawn art. Where the mesh draws the +/// contents as large as the drawn art but elsewhere, the envelope opens live with its mesh moved +/// onto that art; where it draws them at another size, it keeps its drawn art. +#[test] +fn an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art() { + use kurbo::Shape as _; + use vectorcraft_doc::live::EnvelopeKind; + // The top object of `an_envelope_whose_contents_are_edited_opens_live_in_edit_contents`. + let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0); + let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) { + ((0, 1), (1, 1)) => (70.0, 95.0), + ((1, 1), (0, 1)) => (130.0, 95.0), + _ => { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + } + }; + let mesh = mesh_text(1, 1, &at, &handle, None); + let mut bp = kurbo::BezPath::new(); + bp.move_to((40.0, 20.0)); + bp.curve_to((70.0, 5.0), (130.0, 5.0), (160.0, 20.0)); + bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0)); + bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0)); + bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0)); + bp.close_path(); + let shaped = |a: Affine| EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&(a * bp.clone())), corners: None }; + let keys = ENVELOPE_KEYS.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)"); + // The contents and the drawn art lie 30 points right and 15 down of where the mesh in the + // dictionary draws them. + let moved = Affine::translate((30.0, 15.0)); + let contents = Rect::new(70.0, 35.0, 190.0, 95.0); + let read_with = |drawn: &EnvelopeKind| { + let block = format!("0 g\n{mesh}"); + let red = format!("{RED}{}f\n", rect_path(contents.x0, 100.0 - contents.y1, contents.x1, 100.0 - contents.y0)); + read_ok(&layer(&envelope_parts(&red, &keys, &block, 3, &drawn_box(drawn, contents, contents)))) + }; + let r = read_with(&shaped(moved)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, kind, _, editing, _) = live_envelope(&a[0]); + assert!(editing); + let EnvelopeKind::TopObject { path, .. } = kind else { panic!("{kind:?}") }; + // Moved as far as the drawn art's box lies from the box of what the mesh draws. + let (got, want) = (path.to_bezpath().bounding_box(), (moved * bp.clone()).bounding_box()); + assert!((got.origin() - want.origin()).hypot() < 0.01 && (got.size() - want.size()).to_vec2().hypot() < 1e-6, "{got:?} {want:?}"); + assert_eq!(content.len(), 1, "{content:#?}"); + assert_eq!(content[0].geometric_bounds(), Some(contents)); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + // Drawn art moved and a tenth larger isn't the mesh's art moved: it stays. + let r = read_with(&shaped(moved * Affine::scale_about(1.1, (100.0, 50.0)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(r.warnings, [super::envelope::DRAWN]); +} + /// An envelope that doesn't read (a warp style number past the list, a mesh envelope whose shape /// isn't a mesh, one whose plugin never closes it) keeps its drawn art, with one note; the art /// round it is read as ever. diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 2b7b421bba..f9983c6e83 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5846,6 +5846,67 @@ fn a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art() { assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings); } +/// [`type_envelope_on`] while its contents are edited: the text object in the group before its +/// dictionary, and `shape` in its contents' block, where the editing data then saves the mesh. +fn type_envelope_editing(keys: &str, shape: &str, drawn: &str) -> String { + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; + format!( + "{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden(&format!("u\n{}U\n", text_object(0))), + hidden(&format!("/ArtDictionary :\n{}", keys.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)"))), + hidden(&format!("0 g\n{shape}")), + ) +} + +/// While the contents of an envelope of type are edited, its grid is kept in its dictionary, and in +/// some files it lies away from the drawn art by a move, the type with the drawn art. A warp of type +/// whose saved grid draws the type as large as its drawn art but elsewhere opens live as a mesh +/// envelope on that grid moved onto the drawn art (#3179). +#[test] +fn a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art() { + use vectorcraft_doc::live::EnvelopeKind; + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let mut text = made_envelope_type(&doc); + text.visible = true; + // The drawn art is the grid's moved 10 points left and 25 down. + let by = kurbo::Vec2::new(-10.0, 25.0); + let EnvelopeKind::Mesh { rows, cols, points, handles } = rising_kind() else { panic!("a mesh") }; + let want: Vec = points.iter().map(|p| *p + by).collect(); + let kind = EnvelopeKind::Mesh { rows, cols, points: want.clone(), handles }; + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + let shaped = Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { + content: vec![std::sync::Arc::new(text)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options, + frame: Box::default(), + }, + ); + let (drawn, page) = drawn_envelope(&shaped); + let art = native_with_text( + &layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_editing(&arc_keys(0.3), &rising_grid(), &drawn))), + &doc, + ); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); + assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{:?}", r.warnings); + let mut live = None; + r.document.walk(|n| { + if let NodeKind::Envelope { kind, editing, .. } = &n.kind { + live = Some(((**kind).clone(), *editing)); + } + }); + let Some((EnvelopeKind::Mesh { points, .. }, true)) = live else { panic!("not a mesh envelope in Edit Contents: {live:?}") }; + assert!(points.iter().zip(&want).all(|(p, q)| (*p - *q).hypot() < 0.05), "{points:?}"); +} + /// A warp of type tried again on its saved grid leaves a check for each envelope of type still to /// come: with one check too few for both, the next envelope, whose first try opens it, gets it. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a18b717056..56d25f12a2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | | 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. | | 2026-11-11 | minor | `.ai` and EPS import: stroked type in an envelope with Distort Appearance is tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, for a stroke on its appearance or on a group round it as well as its characters'; it was mapped from its bounds with strokes, which reach as far as a miter join may at a corner. Tests `ai::envelope::tests::stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke`, `type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes`, `a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight` (part of #3179) | | 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 764b7ebe80..240ba7dbf9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -1424,7 +1424,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. + In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). From d12a24f04a1dc1d252603cd55810b63ee47f3fc4 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 11 Nov 2026 14:48:24 +0400 Subject: [PATCH 09/49] i18n(ka): update the Georgian catalog --- crates/ui-egui/src/i18n/ka.tsv | 101 ++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8be7e25840..673c6a5f8c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -522,6 +522,7 @@ Show Guides დამხმარე ხაზების ჩვენება Lock Guides დამხმარე ხაზების ჩაკეტვა Lock Guides by Default დამხმარე ხაზების ნაგულისხმევად ჩაკეტვა + Artboard Guides with Cmd/Ctrl + Ruler Drag სამუშაო დაფის დამხმარე ხაზები Cmd/Ctrl + სახაზავიდან გადათრევით Unlock Guides დამხმარე ხაზების განბლოკვა Make Guides დამხმარე ხაზების შექმნა Release Guides დამხმარე ხაზების მოხსნა @@ -662,6 +663,20 @@ Other Library… სხვა ბიბლიოთეკა… Graphic Style Libraries გრაფიკული სტილების ბიბლიოთეკები Shadows and Glows ჩრდილები და ნათებები + Game HUD თამაშის HUD + Health Fill სიცოცხლე + Mana Glow მანა + Damage Flash ზიანი + Quest Outline ქვესტი + Coin Shine მონეტა + Boss Alert ბოსი + Status Effects სტატუსის ეფექტები + Poison Tint შხამი + Freeze Edge გაყინვა + Burn Aura დაწვა + Stun Pulse დაბნეულობა + Stealth Fade შენიღბვა + Shield Buff ფარი Outlines and Rules კონტურები და ხაზები Hand-Drawn ხელით დახატული Gradient Finishes გრადიენტული ზედაპირები @@ -1987,6 +2002,8 @@ digits Malayalam მალაიალამური Green მწვანე Shifts the tints of global and spot colors; other colors stay. ცვლის გლობალური და სპოტ-ფერების ტონირებებს; სხვა ფერები უცვლელი რჩება. Tint ტონირება + Fill Tint შევსების ტონირება + Stroke Tint კონტურის ტონირება Color Guide Options ფერის გზამკვლევის პარამეტრები Less ნაკლები Steps: ბიჯები: @@ -2335,6 +2352,7 @@ digits Malayalam მალაიალამური File Dimensions ფაილის ზომები Transforms გარდაქმნები Background Color: ფონის ფერი: + Guide Label: დამხმარე ხაზების იარლიყი: Show Center Mark ცენტრის ნიშნის ჩვენება Show Cross Hairs ჯვარედინი ხაზების ჩვენება Show Video Safe Areas ვიდეოს უსაფრთხო არეების ჩვენება @@ -3300,6 +3318,12 @@ theme Appearance იერსახე Real-time Drawing and Editing რეალურ დროში ხატვა და რედაქტირება Render Threads (-1 = Automatic) ნაკადები (-1 = ავტომატური) Reset Preferences პარამეტრების ჩამოყრა + Export Preferences… პარამეტრების ექსპორტირება… + Import Preferences… პარამეტრების იმპორტირება… + Save the applied preferences to a JSON file გამოყენებული პარამეტრების შენახვა JSON ფაილში + Apply the preferences in a file Export Preferences saved პარამეტრების გამოყენება ფაილიდან, რომელიც „პარამეტრების ექსპორტირებამ“ შეინახა + Export Preferences პარამეტრების ექსპორტირება + Import Preferences პარამეტრების იმპორტირება Search Preferences პარამეტრებში ძიება No settings found პარამეტრები ვერ მოიძებნა Scale Corners კუთხეების მასშტაბირება @@ -3641,6 +3665,8 @@ font-technology Variable ცვლადი Preferences პარამეტრები Previous წინა Restore every preference to its default (applied on OK) ყველა პარამეტრის ნაგულისხმევ მნიშვნელობაზე დაბრუნება (გამოიყენება OK-ზე დაჭერისას) + Reset all preferences to their defaults? დავაბრუნოთ ყველა პარამეტრი ნაგულისხმევ მნიშვნელობებზე? + The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. ნაგულისხმევი მნიშვნელობები გამოიყენება, როცა პარამეტრებში OK-ს დააჭერთ. იქ გაუქმება ინარჩუნებს თქვენს მიმდინარე პარამეტრებს. Smaller ↔ Larger უფრო პატარა ↔ უფრო დიდი Smart Guides (View → Smart Guides) ჭკვიანი დამხმარე ხაზები (ხედი → ჭკვიანი დამხმარე ხაზები) Couldn't save recovery data: {e} აღდგენის მონაცემები ვერ შეინახა: {e} @@ -4026,6 +4052,7 @@ font-technology Variable ცვლადი @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis უნდა იყოს "vertical", "horizontal" ან კუთხე გრადუსებში და არა `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" ფონი {other}: transparent, white, black ან ფერი, მაგალითად "#ff8800" @msg bad color {v} არასწორი ფერი {v} +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` არის null, ფენის ფერის სახელი ან #rrggbb @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio უნდა იყოს რიცხვი 0,1-დან 10-მდე @msg bad colour `{hex}` (#rrggbb) არასწორი ფერი `{hex}` (#rrggbb) @msg bad page tree გვერდების არასწორი ხე @@ -4080,6 +4107,11 @@ font-technology Variable ცვლადი @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) შიდა შეცდომა `{cmd}`-ში: {msg} (დოკუმენტი წინანდელ მდგომარეობაში დარჩა; გთხოვთ, შეგვატყობინოთ ამ ხარვეზის შესახებ) @msg internal error: {msg} (please report this bug) შიდა შეცდომა: {msg} (გთხოვთ, შეგვატყობინოთ ამ ხარვეზის შესახებ) @msg invalid PDF setting: {0} PDF-ის არასწორი პარამეტრი: {0} +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): ის იწერება UserUnit {unit}-ით, ამიტომ აპები, რომლებიც UserUnit-ს უგულებელყოფენ, მას ამდენჯერ პატარას აჩვენებენ +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): {label} გვერდები იწერება UserUnit-ის გარეშე, პუნქტებში, და აპები, რომლებიც ლიმიტს იცავენ, გვერდს 200 in-ზე ჭრიან +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5-ს არ აქვს UserUnit, რომელიც 14 400 pt-ზე (200 in) გრძელ გვერდს ზომის შესანარჩუნებლად სჭირდება: ფაილი იწერება როგორც PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units სამუშაო დაფა {n}-ის გვერდი არის {size}, ძალიან გრძელი და ვიწრო PDF-ის 3-დან 14 400 ერთეულამდე გვერდის მხარეებისთვის: ის იწერება UserUnit {unit}-ით, რომელიც მოკლე მხარეს 3 ერთეულზე ან მეტზე ინარჩუნებს, ხოლო ზღვრის დამცველი აპები გრძელ მხარეს 14 400 ერთეულამდე ჭრიან +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} ფაილები ინახავს ნამდვილ რიცხვებს {max}-მდე, სამუშაო დაფა {n}-ის {side} pt სიგრძის გვერდს კი უფრო დიდი სჭირდება: აირჩიეთ PDF 1.6 ან უფრო ახალი PDF/X სტანდარტის გარეშე, ან უფრო პატარა სამუშაო დაფა @msg invalid image data for `{0}` არასწორი გამოსახულების მონაცემები `{0}`-სთვის @msg invalid parameters for `{cmd}`: {msg} `{cmd}`: არასწორი პარამეტრები — {msg} @msg is not an image VectorCraft reads ეს არ არის გამოსახულება, რომელსაც VectorCraft კითხულობს @@ -4393,6 +4425,20 @@ font-technology Variable ცვლადი @msg unknown overprints `{o}` (preserve, simulate or discard) უცნობი ზედდაბეჭდვა `{o}` (preserve, simulate ან discard) @msg unknown panel `{raw}` უცნობი პანელი `{raw}` @msg unknown preference `{key}` უცნობი პარამეტრი `{key}` +@msg Preferences imported. პარამეტრები იმპორტირებულია. +@msg not a preferences file: {e} ეს არ არის პარამეტრების ფაილი: {e} +@msg not a preferences file: no JSON object ეს არ არის პარამეტრების ფაილი: JSON ობიექტი არ არის +@msg not a preferences file (format `{format}`) ეს არ არის პარამეტრების ფაილი (ფორმატი `{format}`) +@msg not a preferences file: no `format` ეს არ არის პარამეტრების ფაილი: `format` არ არის +@msg not a preferences file: no `version` ეს არ არის პარამეტრების ფაილი: `version` არ არის +@msg not a preferences file: no `prefs` ეს არ არის პარამეტრების ფაილი: `prefs` არ არის +@msg the file was saved by a newer version of VectorCraft (version {v}) ფაილი შენახულია VectorCraft-ის უფრო ახალი ვერსიით (ვერსია {v}) +@msg the file holds no preferences ფაილში პარამეტრები არ არის +@msg `{key}` names a folder, which preferences files leave out `{key}` საქაღალდეს მიუთითებს, პარამეტრების ფაილები კი საქაღალდეებს არ შეიცავს +@msg the file is larger than 1 MB ფაილი 1 MB-ზე დიდია +@msg give `path` or `data` მიუთითეთ `path` ან `data` +@msg `{key}` must be an object `{key}` უნდა იყოს ობიექტი +@msg `prefs.import` runs on its own, not as a batch step `prefs.import` ცალკე სრულდება და არა პაკეტის ნაბიჯად @msg unknown preset (one of {_1}) უცნობი პრესეტი (ერთ-ერთი: {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) უცნობი პრესეტი `{name}` (high, medium, low ან შენახული: იხ. flattener.presets.list) @msg unknown preset `{name}` (see flattener.presets.list) უცნობი პრესეტი `{name}` (იხ. flattener.presets.list) @@ -4959,7 +5005,8 @@ font-technology Variable ცვლადი Bevel both sides წაკვეთა ორივე მხარეს Presets პრესეტები @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. ობიექტს ერთი 3D ეფექტი შეიძლება ჰქონდეს. შეცვალეთ არსებული ეფექტის პარამეტრები ან ჯერ წაშალეთ ის. -@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი ან ჯგუფი. ჯერ გარდაქმენით გამოსახულებები და სხვა ობიექტები ტრაექტორიებად. +@msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი, ჯგუფი, გადასვლა ან სიმბოლოს ეგზემპლარი. ჯერ გაშალეთ სხვა ობიექტები. +@msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: გამოსახულებიდან მყარი სხეულის აგება შეუძლებელია. ჯერ მოახდინეთ მისი ვექტორიზაცია ბრძანებით ობიექტი › გამოსახულების ვექტორიზაცია › შექმნა. @msg {label} requires a shape with at least two anchors {label}: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით @msg The shape is too complex for a 3D effect ფიგურა ზედმეტად რთულია 3D ეფექტისთვის @msg The 3D surface is too complex; simplify the shape 3D ზედაპირი ზედმეტად რთულია; გაამარტივეთ ფიგურა @@ -4978,6 +5025,7 @@ file-filter Swatch libraries ფერის ნიმუშების ბი file-filter Flattener presets გაბრტყელების პრესეტები file-filter PDF presets PDF-ის პრესეტები file-filter Print presets ბეჭდვის პრესეტები +file-filter Preferences პარამეტრები file-filter Export presets ექსპორტის პრესეტები file-filter SVG Compressed შეკუმშული SVG file-filter PDF-compatible .ai PDF-თან თავსებადი .ai @@ -5138,6 +5186,7 @@ imageTrace Outline კონტური Stop Script სკრიპტის შეჩერება Show External Script Warning გარე სკრიპტის გაფრთხილების ჩვენება A Script Is Running სკრიპტი მუშაობს + Script Alert სკრიპტის შეტყობინება A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. სკრიპტს შეუძლია ღია დოკუმენტების შეცვლა და მის მიერ გაშვებული ბრძანებებით ფაილების ჩაწერა. გაუშვით მხოლოდ სანდო სკრიპტები. Do not show this warning again აღარ აჩვენო ეს გაფრთხილება Stop the script and undo what it did? Then quit again. შეჩერდეს სკრიპტი და გაუქმდეს მისი ცვლილებები? შემდეგ ხელახლა გამოდით. @@ -5151,6 +5200,9 @@ imageTrace Outline კონტური @msg the script is too large to run სკრიპტი გასაშვებად ძალიან დიდია @msg a drag or an undo group is open; finish it before running a script ღიაა გადათრევა ან გაუქმების ჯგუფი; დაასრულეთ სკრიპტის გაშვებამდე @msg the script left a document that cannot be kept სკრიპტმა დატოვა დოკუმენტი, რომლის შენახვა შეუძლებელია +@msg the script is no longer in the Scripts folder სკრიპტი აღარ არის Scripts საქაღალდეში + Some scripts were not listed ზოგიერთი სკრიპტი არ არის ჩამოთვლილი + The Scripts folder cannot be read Scripts საქაღალდის წაკითხვა შეუძლებელია @msg the script's run is no longer available სკრიპტის გაშვება აღარ არის ხელმისაწვდომი @msg a failed command changed undo steps that could not be put back, so the script was stopped წარუმატებელმა ბრძანებამ დაბრუნების ნაბიჯები შეცვალა და მათი პირვანდელ მდგომარეობაში მოყვანა ვერ მოხერხდა, ამიტომ სკრიპტი შეჩერდა @msg a recovery copy could not be written, so the script was not run აღდგენის ასლის ჩაწერა ვერ მოხერხდა, ამიტომ სკრიპტი არ გაეშვა @@ -5274,6 +5326,7 @@ imageTrace Outline კონტური Preview (1 inch on the page) წინასწარი ნახვა (1 დუიმი გვერდზე) Set as Default ნაგულისხმევად დაყენება out from a highlighted edge to place its dimension | — გამოკვეთილი კიდიდან გარეთ, მისი ზომის დასასმელად | + two points, then move and click to place an aligned, horizontal or vertical dimension | — ორ წერტილზე, შემდეგ გადაადგილეთ და დააწკაპუნეთ გასწორებული, ჰორიზონტალური ან ვერტიკალური ზომის დასასმელად | cancels | Double-click the tool for options — გაუქმება | პარამეტრებისთვის ორჯერ დაწკაპეთ ხელსაწყოზე Angular კუთხური Away from the Line ხაზისგან მოშორებით @@ -5303,14 +5356,17 @@ imageTrace Outline კონტური @msg Invalid drawing scale ნახაზის არასწორი მასშტაბი @msg Invalid real scale რეალური ზომის არასწორი მასშტაბი @msg Measured anchor no longer exists გაზომილი საყრდენი წერტილი აღარ არსებობს +@msg Invalid measured point გაზომილი წერტილი არასწორია @msg Measured segment no longer exists გაზომილი სეგმენტი აღარ არსებობს @msg Non-finite dimension ზომა სასრული რიცხვი არ არის +@msg Only linear dimensions are horizontal or vertical მხოლოდ ხაზოვანი ზომებია ჰორიზონტალური ან ვერტიკალური @msg Scale Text Attributes: selected type has no scale to bake ტექსტის ატრიბუტების მასშტაბირება: არჩეულ ტექსტს არ აქვს გადასაყვანი მასშტაბი @msg Scale must be drawing:real, for example 1:10 მასშტაბი უნდა იყოს ნახაზი:რეალური, მაგალითად 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 მასშტაბის შეფარდება 1:1000000-სა და 1000000:1-ს შორის უნდა იყოს @msg Second measured segment no longer exists მეორე გაზომილი სეგმენტი აღარ არსებობს @msg Unknown dimension mode ზომის უცნობი რეჟიმი @msg Unknown dimension option {k} ზომის უცნობი პარამეტრი {k} +@msg Unknown dimension orientation ზომის უცნობი ორიენტაცია @msg Unknown dimension style ზომის უცნობი სტილი @msg Unknown unit უცნობი ერთეული Close the circular ends of open profiles. Partial sweeps keep their cut sides open. ღია პროფილების წრიული ბოლოების დახურვა. ნაწილობრივი ბრუნვისას ჭრის მხარეები ღია რჩება. @@ -5886,6 +5942,20 @@ axis Y Axis Y ღერძი Show its progress, see Cancel and write log lines პროგრესის ჩვენება, გაუქმების შემჩნევა და ჟურნალის სტრიქონების ჩაწერა Keep settings of its own საკუთარი პარამეტრების შენახვა Keep a cache of its own, which you can clear საკუთარი ქეშის შენახვა, რომლის გასუფთავებაც შეგიძლიათ + Run these commands on the document: {ids} ამ ბრძანებების გაშვება დოკუმენტზე: {ids} +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call „{name}“ `commandAllowlist`-ში ასახელებს `{id}`-ს, რომლის გამოძახებაც პლაგინს არ შეუძლია +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: მანიფესტის `commandAllowlist` მას არ ასახელებს +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: ID ან არგუმენტები მოდულის მეხსიერების გარეთაა +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: ID ან არგუმენტები UTF-8 არ არის +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: არგუმენტები JSON ობიექტი არ არის +@msg only a filter with the usual budget declares `commandAllowlist` `commandAllowlist`-ის გამოცხადება მხოლოდ ჩვეულებრივი ბიუჯეტის ფილტრს შეუძლია +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) ბრძანებების გამოძახებები ({MAX_CALLS} გამოძახება და {MAX_CALL_BYTES} ბაიტი ერთ გაშვებაზე) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) ბრძანების გამოძახების ზომა (vc_call `{shown}`: {len} ბაიტი, ლიმიტი {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` მაქსიმუმ {MAX_ALLOWED_COMMANDS} ბრძანება `commandAllowlist`-ში +@msg a plug-in may not call this command პლაგინს ამ ბრძანების გამოძახება არ შეუძლია +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist`-ს სჭირდება ნებართვა `commands`, ხოლო `commands`-ს სჭირდება `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` ასახელებს `{shown}`-ს, რომელიც ბრძანების ID არ არის ან ორჯერაა ჩამოთვლილი Clear Plug-in Cache პლაგინის ქეშის გასუფთავება @msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `{permission}` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} მოდულს შემოაქვს `env.{name}` ჰოსტისგან განსხვავებული ტიპით: ჰოსტის ტიპია {signature} @@ -5980,6 +6050,12 @@ axis Y Axis Y ღერძი Flag დროშა Crosshair სამიზნე Checkpoint საკონტროლო წერტილი + Inventory Icons ინვენტარის ხატულები + Sword მახვილი + Shield ფარი + Potion წამალი + Key გასაღები + Gem ძვირფასი ქვა Open Symbol Library სიმბოლოების ბიბლიოთეკის გახსნა Save Symbol Library სიმბოლოების ბიბლიოთეკის შენახვა Save Symbol Library… სიმბოლოების ბიბლიოთეკის შენახვა… @@ -6034,6 +6110,11 @@ axis Y Axis Y ღერძი User Brush Library 9 მომხმარებლის ფუნჯების ბიბლიოთეკა 9 User Brush Library 10 მომხმარებლის ფუნჯების ბიბლიოთეკა 10 Art Ribbons ნახატის ლენტები + Zigzag ზიგზაგი + Burst Ray დარტყმის სხივი + Dash Trail წყვეტილი კვალი + Speed Line სიჩქარის ხაზი + Game Trails თამაშის კვალი Calligraphic Nibs კალიგრაფიული წვერები Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები @@ -6761,3 +6842,21 @@ font-script Japanese იაპონური Add to Dictionary ლექსიკონში დამატება No Suggestions შემოთავაზებები არ არის Copies per Sheet (as many as fit, in a grid) ასლები ფურცელზე (რამდენიც დაეტევა, ბადედ) + Edit Custom Dictionary მომხმარებლის ლექსიკონის რედაქტირება + Entry: ჩანაწერი: +# Script Console + Script Console სკრიპტების კონსოლი + Clear Script Console სკრიპტების კონსოლის გასუფთავება + Copy Script Console სკრიპტების კონსოლის კოპირება + Copy All ყველაფრის კოპირება + Copy Selected მონიშნულის კოპირება + Finished დასრულდა + Ended with an error დასრულდა შეცდომით + Out of fuel ინსტრუქციების ლიმიტი ამოიწურა + Ended at a host limit დასრულდა ჰოსტის ლიმიტზე + Ended at a limit დასრულდა ლიმიტზე + Stopped შეჩერდა + Ended by an internal fault დასრულდა შიდა შეცდომის გამო + {ms} ms, {calls} host calls {ms} ms, ჰოსტის {calls} გამოძახება + {dropped} earlier lines dropped, {cut} lines cut {dropped} ძველი სტრიქონი წაიშალა, {cut} სტრიქონი შემოკლდა +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ From 4c4e4dda2203b43207cdb58786f60d43b40616ee Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 11 Nov 2026 15:01:23 +0400 Subject: [PATCH 10/49] i18n(ka): update the Georgian catalog --- crates/ui-egui/src/i18n/ka.tsv | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 673c6a5f8c..318a1556c2 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -92,6 +92,7 @@ Spelling მართლწერა Auto Spell Check მართლწერის ავტომატური შემოწმება Check Spelling… მართლწერის შემოწმება… + Check Spelling მართლწერის შემოწმება Edit Custom Dictionary… მომხმარებლის ლექსიკონის რედაქტირება… Edit Colors ფერების რედაქტირება Recolor Artwork… ნახატის ხელახლა შეფერადება… @@ -362,6 +363,8 @@ Stroke Pattern კონტურის პატერნი Patterns პატერნები Stroke Weight კონტურის სისქე + Stroke Weight Up კონტურის სისქის გაზრდა + Stroke Weight Down კონტურის სისქის შემცირება Graphic Style გრაფიკული სტილი Symbol Instance სიმბოლოს ეგზემპლარი Link Block Series დაკავშირებული ბლოკების სერია @@ -451,6 +454,13 @@ Feather… კიდეების შერბილება… SVG Filters SVG ფილტრები Apply SVG Filter… SVG ფილტრის გამოყენება… + Apply SVG Filter SVG ფილტრის გამოყენება + SVG Filter SVG ფილტრი + New SVG Filter ახალი SVG ფილტრი + Edit SVG Filter SVG ფილტრის რედაქტირება + Delete SVG Filter SVG ფილტრის წაშლა + Import SVG Filter SVG ფილტრის იმპორტირება + Import SVG Filter… SVG ფილტრის იმპორტირება… Warp გაღუნვა Arc… რკალი… Arc Lower… ქვედა რკალი… @@ -1515,6 +1525,13 @@ digits Malayalam მალაიალამური Radial Gradient რადიალური გრადიენტი Reset to White, Black თეთრზე და შავზე დაბრუნება Reverse Gradient გრადიენტის შებრუნება + Gradient Presets გრადიენტის პრესეტები + Apply Gradient Preset გრადიენტის პრესეტის გამოყენება + Save Gradient Preset გრადიენტის პრესეტის შენახვა + Save Gradient Preset… გრადიენტის პრესეტის შენახვა… + Delete Gradient Preset გრადიენტის პრესეტის წაშლა + Show Presets პრესეტების ჩვენება + Hide Presets პრესეტების დამალვა Save to Swatches ფერის ნიმუშებში შენახვა Delete Graphic Style (Alt-click: without asking) გრაფიკული სტილის წაშლა (Alt-დაწკაპუნებით: დაუკითხავად) Delete the graphic style “{name}”? წაიშალოს გრაფიკული სტილი „{name}“? @@ -1553,6 +1570,7 @@ digits Malayalam მალაიალამური Rename Preset პრესეტის სახელის შეცვლა Save Image Trace Preset გამოსახულების ვექტორიზაციის პრესეტის შენახვა Delete Image Trace Preset გამოსახულების ვექტორიზაციის პრესეტის წაშლა + Cancel Image Trace გამოსახულების ვექტორიზაციის გაუქმება Method: მეთოდი: Mode: რეჟიმი: Noise ხმაური @@ -2946,6 +2964,7 @@ theme Appearance იერსახე Reshape Segment სეგმენტის ფორმის შეცვლა Split Path ტრაექტორიის გაყოფა Activate Document დოკუმენტის გააქტიურება + Move Document დოკუმენტის გადატანა Delete წაშლა Document JSON დოკუმენტის JSON Duplicate დუბლირება @@ -2961,6 +2980,8 @@ theme Appearance იერსახე Effects ეფექტები Move Effect ეფექტის გადატანა Remove Effect ეფექტის წაშლა + Duplicate Effects ეფექტების დუბლირება + Remove Effects ეფექტების წაშლა Expand Options გაშლის პარამეტრები File Info ფაილის ინფორმაცია Batch პაკეტური დამუშავება @@ -3364,6 +3385,7 @@ theme Appearance იერსახე Use Japanese Crop Marks იაპონური ჭრის ნიშნების გამოყენება Use Low Resolution Proxy for Linked EPS დაბალი გარჩევადობის პროქსის გამოყენება დაკავშირებული EPS-ისთვის Use Precise Cursors ზუსტი კურსორების გამოყენება + Caps Lock Cursor State Caps Lock-ის მდგომარეობა კურსორისთვის Use Preview Bounds გადახედვის საზღვრების გამოყენება When pasting text ტექსტის ჩასმისას XML ID XML ID @@ -3520,6 +3542,8 @@ axis Both ორივე List Folder საქაღალდის სია Area Type Options არის ტექსტის პარამეტრები Reshape Type Area ტექსტის არის ფორმის შეცვლა + Reshape Type Path ტექსტის ტრაექტორიის ფორმის შეცვლა + Move Type Path Handle ტექსტის ტრაექტორიის სახელურის გადაადგილება Scale მასშტაბი Feet & Inches ფუტები და დუიმები Pixels პიქსელები @@ -3765,6 +3789,7 @@ font-technology Variable ცვლადი Flip არეკვლა Couldn't {} {name}: {e} ვერ მოხერხდა {}: {name}: {e} Delete Character Style სიმბოლოს სტილის წაშლა + Edit Character Style სიმბოლოს სტილის რედაქტირება Delete Layer შრის წაშლა Delete Paragraph Style აბზაცის სტილის წაშლა Duplicate Character Style სიმბოლოს სტილის დუბლირება @@ -5183,6 +5208,7 @@ mark-style Japanese იაპონური imageTrace Outline კონტური Run გაშვება Run Script სკრიპტის გაშვება + List Installed Scripts დაინსტალირებული სკრიპტების სია Stop Script სკრიპტის შეჩერება Show External Script Warning გარე სკრიპტის გაფრთხილების ჩვენება A Script Is Running სკრიპტი მუშაობს @@ -5776,6 +5802,8 @@ imageTrace Outline კონტური Clear History ისტორიის გასუფთავება New Document from State ახალი დოკუმენტი მდგომარეობიდან Continue in Background ფონურ რეჟიმში გაგრძელება + Cancel Opening გახსნის გაუქმება + Reading the file… ფაილი იკითხება… Artboard {n} of {total} სამუშაო დაფა {n} / {total} File {n} of {total} ფაილი {n} / {total} Auto Leading: ავტომატური სტრიქონთა დაშორება: @@ -6325,6 +6353,7 @@ anchor Points: წერტილები: Parametric Shape Tool პარამეტრული ფიგურის ხელსაწყო Parametric Shape პარამეტრული ფიგურა Edit Parametric Shape პარამეტრული ფიგურის რედაქტირება + Configure Shape ფიგურის პარამეტრების დაყენება Convert to Parametric Shape პარამეტრულ ფიგურად გარდაქმნა Ring რგოლი Heart გული @@ -6605,10 +6634,28 @@ breakText Glyphs გლიფები Malayalam მალაიალამური Odia ორია Urdu ურდუ + Assamese ასამური + Sanskrit სანსკრიტი + German: Swiss გერმანული: შვეიცარია + Catalan კატალანური + Danish დანიური + Swedish შვედური + Finnish ფინური + Norwegian ნორვეგიული + Polish პოლონური + Czech ჩეხური + Russian რუსული + Bulgarian ბულგარული + Serbian: Cyrillic სერბული: კირილიცა + Serbian: Latin სერბული: ლათინიცა + Croatian ხორვატული + Turkish თურქული + Chinese ჩინური VectorCraft {release} and later (version {n}) VectorCraft {release} და უფრო ახალი (ვერსია {n}) # Document Tools add-on: Export All Open Documents (#4920). Export All Open Documents as PDF ყველა ღია დოკუმენტის ექსპორტი PDF-ად Export All Open Documents as SVG ყველა ღია დოკუმენტის ექსპორტი SVG-ად + Export All Open Documents ყველა ღია დოკუმენტის ექსპორტი @msgplural Exported {n} document|Exported {n} documents ექსპორტირებულია დოკუმენტი ({n}) @msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} ექსპორტირებულია დოკუმენტი ({n}); {title} არ ჩაიწერა: {error} # Document Tools add-on: Batch Trace Folder (#4921). @@ -6622,6 +6669,7 @@ breakText Glyphs გლიფები Static Symbol სტატიკური სიმბოლო Symbol Instance Appearance სიმბოლოს ეგზემპლარის იერსახე Reset Symbol Instance სიმბოლოს ეგზემპლარის ჩამოყრა + Select Symbol Instance Part სიმბოლოს ეგზემპლარის ნაწილის მონიშვნა @msg type must be static or dynamic, not {v} type უნდა იყოს static ან dynamic და არა {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` სტატიკური სიმბოლოა: ჯერ სიმბოლოს ტიპად დააყენეთ „დინამიკური“ @msg Only the fill, stroke, opacity and blend of a path, compound path or type selected in a symbol instance can change სიმბოლოს ეგზემპლარში მონიშნული მრუდის, შედგენილი მრუდის ან ტექსტის მხოლოდ შევსება, კონტური, გამჭვირვალობა და შერევა შეიძლება შეიცვალოს @@ -6843,6 +6891,10 @@ font-script Japanese იაპონური No Suggestions შემოთავაზებები არ არის Copies per Sheet (as many as fit, in a grid) ასლები ფურცელზე (რამდენიც დაეტევა, ბადედ) Edit Custom Dictionary მომხმარებლის ლექსიკონის რედაქტირება + Add to Custom Dictionary მომხმარებლის ლექსიკონში დამატება + Change in Custom Dictionary მომხმარებლის ლექსიკონში შეცვლა + Delete from Custom Dictionary მომხმარებლის ლექსიკონიდან წაშლა + Spelling Suggestions მართლწერის შეთავაზებები Entry: ჩანაწერი: # Script Console Script Console სკრიპტების კონსოლი From a87d996e1d2bb64cffa12a2f1beb44de20765181 Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Wed, 11 Nov 2026 16:38:59 +0500 Subject: [PATCH 11/49] Outline view: a click on a path's outline selects it through an image or a gradient mesh in front (#6437) In Outline view an image was still hit anywhere inside its box and a gradient mesh inside its fill, though Outline draws only the image's frame and the mesh lines. A path behind them could not be picked there. Now an image is hit on its frame (unless Show Images in Outline Mode draws its pixels) and a mesh on its edge and mesh lines. Preview is unchanged. --- crates/doc/src/hit.rs | 10 +++++--- crates/doc/src/hit/tests.rs | 39 ++++++++++++++++++++++++++++++++ crates/tools/src/select/tests.rs | 27 ++++++++++++++++++++++ docs/ui-parity.md | 4 +++- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 14bda9a00b..3412a7ff40 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -870,10 +870,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline) } // An image is hit within its frame (on the frame's edge only with Selection by Path Only, - // #3291), and where its own fills and strokes paint ([`image_paint`]) except in outline - // mode. + // #3291, and in outline mode where the frame is all that is drawn, #6437), and where its + // own fills and strokes paint ([`image_paint`]) except in outline mode. NodeKind::Image(im) => { - if opt.path_only { + if opt.path_only || (opt.outline && !symbols.document().setup.outline_images) { use vectorcraft_geom::kurbo::Shape; let mut frame = Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1); frame.apply_affine(im.xf); @@ -909,6 +909,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio if stroke_contains(&bp, 0.0, opt.tol, p) { return Some(HitKind::Outline); } + // Outline mode draws the mesh lines, not the colours between them (#6437). + if opt.outline { + return stroke_contains(&m.lines().to_bezpath(), 0.0, opt.tol, p).then_some(HitKind::Outline); + } (!opt.path_only && fill_contains(&bp, vectorcraft_geom::FillRule::NonZero, p)).then_some(HitKind::Fill) } _ => None, diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index cb5b19d1a4..2c0a6984d0 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -880,6 +880,45 @@ fn path_only_picks_paths_through_images() { assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame"); } +/// #6437: in outline mode an image is drawn as its frame and a gradient mesh as its mesh lines, +/// so a click inside them, on the outline of a path behind, picks that path, and a click on the +/// frame or a mesh line picks the image or the mesh. In Preview they cover the path as before, +/// and an image whose pixels show in outline mode (Show Images in Outline Mode) still does. +#[test] +fn outline_mode_picks_paths_through_images_and_meshes() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (path, image, under, mesh) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); + let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)); + let im = crate::ImageObject { key: "k".into(), width: 80, height: 80, xf, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap(); + d.insert(Some(l), 2, Node::path(under, shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0)), Appearance::default_art())).unwrap(); + let area = shapes::rectangle(Rect::new(110.0, 110.0, 190.0, 190.0)); + let m = crate::GradientMesh::for_path_with(&area, 2, 2, &|_| (Color::BLACK, 1.0)).unwrap(); + d.insert(Some(l), 3, Node::new(mesh, NodeKind::Mesh(m))).unwrap(); + let outline = HitOptions { outline: true, ..Default::default() }; + let hit = |d: &Document, p: Point, opt| hit_test(d, p, opt).map(|h| h.leaf); + // The image: the path's edge and the inside, then the frame. + let (path_edge, inside, frame) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(89.5, 50.0)); + assert_eq!(hit(&d, path_edge, HitOptions::default()), Some(image), "Preview: the image covers the path"); + assert_eq!(hit(&d, inside, HitOptions::default()), Some(image)); + assert_eq!(hit(&d, path_edge, outline), Some(path), "outline: the path through the image"); + assert_eq!(hit(&d, inside, outline), None, "outline: not inside the image"); + assert_eq!(hit(&d, frame, outline), Some(image), "outline: the image's frame"); + // The mesh: the path's edge and the inside, then its edge and a mesh line inside it. + let (under_edge, inside, edge, line) = (Point::new(120.5, 130.0), Point::new(130.0, 170.0), Point::new(189.5, 130.0), Point::new(150.0, 170.0)); + assert_eq!(hit(&d, under_edge, HitOptions::default()), Some(mesh), "Preview: the mesh covers the path"); + assert_eq!(hit(&d, inside, HitOptions::default()), Some(mesh)); + assert_eq!(hit(&d, under_edge, outline), Some(under), "outline: the path through the mesh"); + assert_eq!(hit(&d, inside, outline), None, "outline: not between the mesh lines"); + assert_eq!(hit(&d, edge, outline), Some(mesh), "outline: the mesh's edge"); + assert_eq!(hit(&d, line, outline), Some(mesh), "outline: a mesh line"); + // Show Images in Outline Mode: the image's pixels are drawn, and it covers the path again. + d.setup.outline_images = true; + assert_eq!(hit(&d, path_edge, outline), Some(image)); +} + /// Type Object Selection by Path Only: point type is picked on its baseline, area type on its /// frame and type on a path on its path, no longer anywhere in its bounds. #[test] diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 2b1151742a..a524d77389 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2426,3 +2426,30 @@ fn a_ruler_guide_under_the_centre_point_keeps_the_pointer() { let drag = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0)); assert!(drag.iter().any(|a| matches!(a, Action::Preview(cmd, _) if cmd == "guide.move")), "the guide drags: {down:?} {drag:?}"); } + +/// #6437: in Outline view, where an image is drawn as its frame, a click inside it on the edge of +/// a path behind selects that path, with the image selected or not. In Preview the image takes +/// the click. +#[test] +fn outline_view_click_through_an_image_selects_the_path_behind() { + use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeKind}; + let mut d = Document::new(500.0, 500.0); + let layer = d.layers[0].id; + let (back, image) = (d.alloc_id(), d.alloc_id()); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)); + d.insert(Some(layer), 0, Node::path(back, square, Appearance::default_art())).unwrap(); + let xf = vectorcraft_geom::Affine::translate((150.0, 150.0)); + let im = ImageObject { key: "k".into(), width: 100, height: 100, xf, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(layer), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap(); + let selects = |a: &[Action], id: NodeId| a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([id.0]))); + let p = paint(); + for picked in [vec![], vec![image]] { + let mut s = Selection::default(); + s.set(picked.clone()); + let outline = ToolContext { outline: true, ..cx(&d, &s, &p) }; + let a = SelectionTool::default().pointer(&outline, &ev(PointerKind::Down, 200.0, 180.0)); + assert!(selects(&a, back), "outline, {picked:?} selected: {a:?}"); + let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 200.0, 180.0)); + assert!(!selects(&a, back), "preview, {picked:?} selected: {a:?}"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a0182867cc..89e846d86a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -438,6 +438,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 | | Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines | | Outline view draws on a white canvas, the artboards as their edges | done | Outline shows only the paths ([Outline mode](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/preview-artwork-as-outlines.html)). VectorCraft's own rule for the canvas: in every interface brightness and Canvas Color the whole canvas is white, pasteboard included; the artboards draw no paper (no Simulate Colored Paper or artboard color, and no paper with Hide Artboards); the artboard names and the ruler origin's crosshair are dark gray; split views and floating windows follow their own view. Tests `outline_draws_on_a_white_canvas_with_artboards_as_edges`, `outline_draws_no_colored_paper`, `a_pane_draws_its_own_views_outline`, `the_ruler_origin_crosshair_shows_on_the_outline_canvas` | +| Outline view: a click selects by the outlines drawn, so it reaches a path behind an image (picked on its frame, unless Show Images in Outline Mode draws its pixels) or a gradient mesh (picked on its mesh lines) | done | #6437; in Outline mode the path is selected directly ([Object selection methods and preferences](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/object-selection-methods-and-preferences.html), Object Selection by Path Only). Type, symbol instances, blends, envelopes and placed documents are still picked within their bounds. Tests `outline_mode_picks_paths_through_images_and_meshes`, `outline_view_click_through_an_image_selects_the_path_behind` | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools runs Arrange in Grid… (grid by row, one row or one column; columns, H and V spacing, Center on Artboard; Preview), Swap Objects, Swap Objects and Sizes, Replace with Top Object… (Actual Size, Bounds or Bounds (Fit Proportionally); Keep Original; Scale Strokes & Effects; Preview), Set Size of Each… (Width and Height each ticked on or off, Constrain Width and Height Proportions with one side, the reference point, Scale Strokes & Effects; Preview) and Zoom to Selection, each one undo step | done | `addon.arrange.*`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Group Overlapping Objects groups the selected objects whose visual bounds overlap or touch (chains included, within one layer or group), each group at its frontmost member's stacking place, one undo step | done | #4928; `addon.arrange.groupOverlapping`, `crates/engine/src/cmd/addons/arrange.rs` and its tests; not a feature of the reference app, so it is not scored | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Randomize Opacity… gives each selected object its own opacity at random between Minimum and Maximum (%), with a Seed that repeats it and Preview, one undo step | done | #4925; `addon.arrange.randomizeOpacity`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored | @@ -507,6 +508,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-10 | minor | Symbol libraries: Inventory Icons joins Basic Shapes, UI Frames and Map Markers as a fourth built-in library (Sword, Shield, Potion, Key, Gem; no default 9-slice). | | 2026-11-11 | minor | Files that record no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files, importer plug-ins) open and revert in Preferences › Units › General instead of points; native, SVG and DXF files and `.ai`/EPS ruler units are kept (#5820). Scores and estimates unchanged. | | 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | From 0984292b5783d784f64c4bbdc9a6df56893803d9 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 11 Nov 2026 16:27:17 +0400 Subject: [PATCH 12/49] i18n(ka): update the Georgian catalog --- crates/ui-egui/src/i18n/ka.tsv | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 7450186fd0..6ca1f456a9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3385,7 +3385,7 @@ theme Appearance იერსახე Use Japanese Crop Marks იაპონური ჭრის ნიშნების გამოყენება Use Low Resolution Proxy for Linked EPS დაბალი გარჩევადობის პროქსის გამოყენება დაკავშირებული EPS-ისთვის Use Precise Cursors ზუსტი კურსორების გამოყენება - Caps Lock Cursor State Caps Lock-ის მდგომარეობა კურსორისთვის + Caps Lock Cursor State Caps Lock-ის მდგომარეობა (კურსორი) Use Preview Bounds გადახედვის საზღვრების გამოყენება When pasting text ტექსტის ჩასმისას XML ID XML ID @@ -6353,7 +6353,7 @@ anchor Points: წერტილები: Parametric Shape Tool პარამეტრული ფიგურის ხელსაწყო Parametric Shape პარამეტრული ფიგურა Edit Parametric Shape პარამეტრული ფიგურის რედაქტირება - Configure Shape ფიგურის პარამეტრების დაყენება + Configure Shape ფიგურის პარამეტრების შეცვლა Convert to Parametric Shape პარამეტრულ ფიგურად გარდაქმნა Ring რგოლი Heart გული @@ -6655,7 +6655,7 @@ breakText Glyphs გლიფები # Document Tools add-on: Export All Open Documents (#4920). Export All Open Documents as PDF ყველა ღია დოკუმენტის ექსპორტი PDF-ად Export All Open Documents as SVG ყველა ღია დოკუმენტის ექსპორტი SVG-ად - Export All Open Documents ყველა ღია დოკუმენტის ექსპორტი + Export All Open Documents ყველა ღია დოკუმენტის ექსპორტირება @msgplural Exported {n} document|Exported {n} documents ექსპორტირებულია დოკუმენტი ({n}) @msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} ექსპორტირებულია დოკუმენტი ({n}); {title} არ ჩაიწერა: {error} # Document Tools add-on: Batch Trace Folder (#4921). @@ -6912,9 +6912,3 @@ font-script Japanese იაპონური {ms} ms, {calls} host calls {ms} ms, ჰოსტის {calls} გამოძახება {dropped} earlier lines dropped, {cut} lines cut {dropped} ძველი სტრიქონი წაიშალა, {cut} სტრიქონი შემოკლდა @msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ - -# Deferred plug-in effects - Refresh Deferred Effects გადადებული ეფექტების განახლება - Deferred Effects გადადებული ეფექტები -@msg no plug-in effect is deferred პლაგინის ეფექტი არ არის გადადებული -@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. გაიხსნა, გადადებული პლაგინის ეფექტები: {n}. ეფექტი › გადადებული ეფექტების განახლება მათ გაუშვებს. From 6ee426ef4b576bd0183384bbba50ac849a9e6ac1 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 11 Nov 2026 16:34:33 +0400 Subject: [PATCH 13/49] i18n(ka): update the Georgian catalog --- crates/ui-egui/src/i18n/ka.tsv | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 6ca1f456a9..97f386dd52 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6912,3 +6912,9 @@ font-script Japanese იაპონური {ms} ms, {calls} host calls {ms} ms, ჰოსტის {calls} გამოძახება {dropped} earlier lines dropped, {cut} lines cut {dropped} ძველი სტრიქონი წაიშალა, {cut} სტრიქონი შემოკლდა @msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ + +# Deferred plug-in effects + Refresh Deferred Effects გადადებული ეფექტების განახლება + Deferred Effects გადადებული ეფექტები +@msg no plug-in effect is deferred პლაგინის ეფექტი არ არის გადადებული +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. გაიხსნა, გადადებული პლაგინის ეფექტები: {n}. ეფექტი › გადადებული ეფექტების განახლება მათ გაუშვებს. From e0e361a834c8fe1b0f47e964ea67c19a06c27457 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 06:08:58 -0800 Subject: [PATCH 14/49] Effects: a pasted SVG filter avoids brush effect names and is refused with no free name Follows #6592. Paste now also skips the names that effects in the document's brush art use when it picks a free name for a pasted SVG filter. When no free name keeps the filter within the 64 KB limit (a one-letter name at the limit with every free letter and digit taken), the paste is refused instead of keeping the name of the document's filter. Clipboard::import_into and import_text_styles return a Result, and their callers pass the error on before the document changes. --- crates/engine/src/cmd/brushlib.rs | 2 +- crates/engine/src/cmd/clipboard/resources.rs | 36 ++++++++++------- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/library.rs | 4 +- crates/engine/src/cmd/patternlib.rs | 7 ++-- crates/engine/src/cmd/swatchlib.rs | 2 +- crates/engine/src/cmd/symbollib.rs | 4 +- crates/engine/src/cmd/textstyles.rs | 4 +- crates/engine/src/cmd/textstyles/tests.rs | 14 +++---- crates/engine/src/tests/svgfilter.rs | 42 ++++++++++++++++++++ docs/mcp.md | 3 +- 11 files changed, 86 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index a5ff58ddf8..b58af91ff8 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -496,7 +496,7 @@ fn add(s: &mut Session, p: &Value) -> Result { // nothing to apply is no edit, and a click that only applies brushes the document has is // named for that. let mut probe = (*st.doc).clone(); - let imported = clip.import_into(&mut probe, &choices, false); + let imported = clip.import_into(&mut probe, &choices, false)?; let doc_names: Vec = picked.iter().map(|n| target(&imported.renamed, n)).collect(); let paths = if apply { brush_targets(&st.doc, &roots(s, p)?) } else { vec![] }; let paint = stroke_paint(s); diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 73bd0b249a..534e0ec89f 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -28,7 +28,7 @@ use vectorcraft_doc::{ use super::super::brushsym::{set_natural_size, stored_natural_size}; use super::super::patterncmds::add_pattern; -use super::super::{Result, bad, unique_name}; +use super::super::{EngineError, Result, bad, unique_name}; use super::Clipboard; /// A resource kind objects refer to (graphic styles by id, the others by name). @@ -1055,8 +1055,9 @@ impl Clipboard { } /// Add the resources the objects use to `d` (inside the paste's undo step) and work out how - /// pasted copies relink to them. - pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Imported { + /// pasted copies relink to them. An error, with `d` unchanged, when a pasted SVG filter finds no + /// free name ([`Self::filter_links`]). + pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Result { self.import_used(d, &self.used(), choices, same_doc, false) } @@ -1065,9 +1066,9 @@ impl Clipboard { /// each style, symbol, pattern and brush comes after the styles, symbols, patterns and brushes it uses /// ([`use_order`]), so that it is compared and relinked with their names here, and each loop /// of definitions that use each other is matched on its own ([`loop_groups`]). - fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported { + fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Result { let mut out = Imported::default(); - out.relink.filters = self.filter_links(d, same_doc); + out.relink.filters = self.filter_links(d, same_doc)?; self.import_images(d, used, same_doc, &mut out); self.import_swatches(d, used, choices, same_doc, &mut out); // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art @@ -1230,7 +1231,7 @@ impl Clipboard { && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty()); - out + Ok(out) } /// How the pasted SVG filter effects relink ([`Relink::filters`]). Each names the document's @@ -1238,8 +1239,8 @@ impl Clipboard { /// filter when pasted back into the document it came from, and elsewhere keeps its own under a /// name no filter or effect of the document gives, so editing or deleting the document's filter leaves it alone. When that name would /// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else - /// a free letter or digit; the filter's id is always its name. - fn filter_links(&self, d: &Document, same_doc: bool) -> BTreeMap<(String, String), (String, String)> { + /// a free letter or digit; the filter's id is always its name. An error when no free name fits. + fn filter_links(&self, d: &Document, same_doc: bool) -> Result> { let mut found = BTreeSet::new(); for n in &self.nodes { node_filters(n, &mut found); @@ -1272,6 +1273,9 @@ impl Clipboard { for root in d.art_roots() { node_filter_names(root, &mut taken); } + for a in vectorcraft_brush::library(d).iter().flat_map(Brush::art) { + node_filter_names(a, &mut taken); + } for g in &d.graphic_styles { appearance_filter_names(&g.appearance, &mut taken); } @@ -1288,13 +1292,17 @@ impl Clipboard { .filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?; Some((new, drawn)) }); - let Some((new, drawn)) = renamed else { continue }; + let Some((new, drawn)) = renamed else { + return Err(EngineError::Other(format!( + "the pasted SVG filter {name:?} draws differently from the document's filter of that name, and no free name fits it" + ))); + }; taken.insert(new.clone()); (new, drawn) }; links.insert((name, xml), to); } - links + Ok(links) } /// Image blobs: a different blob under a key the document uses gets a new key. @@ -1409,13 +1417,13 @@ pub(crate) struct LoadedStyle { /// a pattern added under a free name and a pattern follows a style renamed on the way in. A style /// `d` has under its name, or a numbered copy of it ("Body 2"), that is the same once relinked is /// used; any other is added under a free name. → each style of `src` placed, in order, character -/// styles first. -pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Vec { +/// styles first. An error, as [`Clipboard::import_into`] gives. +pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Result> { let library = OnceCell::new(); let used = Used::of_text_styles(&Defs::of(src, &library), chars, paras); let clip = Clipboard::with_used(src, &used, &library, vec![]); let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::>()); - let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true); + let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true)?; let mut placed: Vec = vec![]; for (para, r, list) in [(false, Res::CharStyle, &clip.char_styles), (true, Res::ParaStyle, &clip.para_styles)] { for s in list { @@ -1425,7 +1433,7 @@ pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, placed.push(LoadedStyle { para, name, added }); } } - placed + Ok(placed) } /// Swatch `w` in `d` ([`existing`]), else added under a free name (swatches and colour groups diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 024ba535b4..3558f1b569 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1332,7 +1332,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho vec![Affine::IDENTITY] }; let (ids, imported, artboards, into) = s.edit(mode.label(), |d, sel| { - let imported = clip.import_into(d, choices, same_doc); + let imported = clip.import_into(d, choices, same_doc)?; // Draw Inside: the clip group of the object drawn into, made on the first paste. let into = inside.map(|t| super::create::draw_inside_group(d, t)).transpose()?; let artboards: Vec = boards diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 48e2d17938..a6d2cbf518 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1303,7 +1303,7 @@ fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result { let choices = SwatchChoices::default(); // The pattern's name in the document, worked out on a copy: nothing to add is no edit. let mut probe = (*s.doc()?.doc).clone(); - let imported = clip.import_into(&mut probe, &choices, false); + let imported = clip.import_into(&mut probe, &choices, false)?; let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name.as_str()).and_then(|r| r["to"].as_str()); let target = renamed.unwrap_or(&name).to_string(); let paint = vectorcraft_doc::pattern::pattern_paint(&target); @@ -1315,7 +1315,7 @@ fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result { in_one_step(s, |s| { if added { s.edit("Add to Swatches", |d, _| { - clip.import_into(d, &choices, false); + clip.import_into(d, &choices, false)?; if !has_swatch(d) { let swatch = d.free_swatch_name(&target); d.swatches.push(vectorcraft_color::Swatch { name: swatch, paint: paint.clone(), global: false, spot: false }); diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index 3e15c563fe..011dfc131c 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -10,6 +10,7 @@ use vectorcraft_doc::pattern::pattern_paint; use vectorcraft_doc::{Appearance, Document, Node, NodeId, PatternDef, TileType}; use vectorcraft_geom::{PathData, Point, Rect, SubPath, shapes}; +use super::Result; use super::clipboard::Clipboard; use super::clipboard::resources::SwatchChoices; use super::swatchlib::LibraryInfo; @@ -84,11 +85,11 @@ pub fn swatch_source(w: &Swatch) -> Option> { /// Add pattern `name` of library `lib` to `d` with the resources its art uses, as pasting art /// painted with it does: the same pattern there already is used, and a name another pattern or /// swatch has gets a number → the pattern's name in `d`. -pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String { +pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> Result { let user = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(pattern_paint(name), Paint::None, 0.0)); - let imported = Clipboard::gather(lib, vec![user]).import_into(d, &SwatchChoices::default(), false); + let imported = Clipboard::gather(lib, vec![user]).import_into(d, &SwatchChoices::default(), false)?; let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name).and_then(|r| r["to"].as_str()); - renamed.unwrap_or(name).to_string() + Ok(renamed.unwrap_or(name).to_string()) } /// The pattern of `d` that is pattern `name` of library `lib`: the same tile options and art under diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index eaa5b8c545..fe2816ed16 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -753,7 +753,7 @@ fn add(s: &mut Session, p: &Value) -> Result { if let (Paint::Pattern { pattern, .. }, Some(src)) = (&w.paint, &patterns) { // The pattern comes with its swatch; a pattern the document has without one // gets one. - let target = patternlib::import(d, src, pattern); + let target = patternlib::import(d, src, pattern)?; let name = match patternlib::pattern_swatch(d, &target) { Some(name) => name, None => { diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index c757d93ea7..02767aef39 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -405,7 +405,7 @@ fn add(s: &mut Session, p: &Value) -> Result { }; // Where the symbols land, worked out on a copy: nothing to add and nothing to place is no edit. let mut probe = (*st.doc).clone(); - let imported = clip.import_into(&mut probe, &choices, false); + let imported = clip.import_into(&mut probe, &choices, false)?; let doc_names: Vec = picked.iter().map(|n| target(&imported.renamed, n)).collect(); let centre = st.doc.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); let at = if place { Some(Point::new(number_or(p, "x", centre.x, C)?, number_or(p, "y", centre.y, C)?)) } else { None }; @@ -413,7 +413,7 @@ fn add(s: &mut Session, p: &Value) -> Result { let mut out = json!({ "library": info.id }); if imported.added > 0 || place { let placed = s.edit("Add to Symbols", |d, sel| { - let imported = clip.import_into(d, &choices, false); + let imported = clip.import_into(d, &choices, false)?; // Every symbol brought in (nested ones too) takes its Symbol Options along. for sym in &clip.symbols { let to = target(&imported.renamed, &sym.name); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 3fd8e8f231..89ec47b276 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -976,12 +976,12 @@ fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result { } let (chars, paras) = (kinds.contains(&Kind::Char), kinds.contains(&Kind::Para)); let mut trial = (*s.doc()?.doc).clone(); - let mut placed = import_text_styles(&src, &mut trial, chars, paras); + let mut placed = import_text_styles(&src, &mut trial, chars, paras)?; // An undo step only when the document changes. The step takes the document the import made, // unless the edit first ends a typing session, which can change the document it starts from. if trial != *s.doc()?.doc { if super::typing_in_progress(s) { - placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d, chars, paras)))?; + placed = s.edit(label, |d, _| import_text_styles(&src, d, chars, paras))?; } else { s.edit(label, |d, _| { *d = trial; diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 73c938dc1a..ab0c7d342e 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1105,7 +1105,7 @@ fn self_referencing_pattern_is_not_added_again() { let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); dst.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [different]})).unwrap(); let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - super::super::clipboard::resources::import_text_styles(&source, target, true, true); + super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); assert!(dst.doc().unwrap().doc.pattern("Dots 2").is_some()); assert!( dst.doc().unwrap().doc.pattern("Dots 2").unwrap().art[0] @@ -1116,7 +1116,7 @@ fn self_referencing_pattern_is_not_added_again() { ); let before = dst.doc().unwrap().doc.clone(); let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); assert!(again.iter().all(|s| !s.added)); assert_eq!(dst.doc().unwrap().doc, before); } @@ -1148,13 +1148,13 @@ fn separate_self_referencing_patterns_are_not_added_again() { dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap(); } let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - super::super::clipboard::resources::import_text_styles(&source, target, true, true); + super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); for k in 0..9 { assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}"); } let before = dst.doc().unwrap().doc.clone(); let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); assert!(again.iter().all(|s| !s.added)); assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len()); assert_eq!(dst.doc().unwrap().doc, before); @@ -1162,7 +1162,7 @@ fn separate_self_referencing_patterns_are_not_added_again() { let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); target.patterns.retain(|p| !p.name.starts_with("Dots8")); let left = target.patterns.len(); - let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); assert_eq!(dst.doc().unwrap().doc.patterns.len(), left + 1); assert!(third.iter().filter(|s| s.name != "Patterned8" && !s.name.starts_with("Patterned8 ")).all(|s| !s.added)); } @@ -1195,13 +1195,13 @@ fn a_long_loop_of_patterns_is_not_added_again() { dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap(); } let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - super::super::clipboard::resources::import_text_styles(&source, target, true, true); + super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); for k in 0..9 { assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}"); } let before = dst.doc().unwrap().doc.clone(); let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap(); assert!(again.iter().all(|s| !s.added)); assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len()); assert_eq!(dst.doc().unwrap().doc, before); diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index 20d95f577c..92f1e49873 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -303,6 +303,48 @@ fn pasted_art_takes_no_name_the_documents_effects_use() { assert_eq!(names, ["Shift 2", "Shift 3"]); } +/// A pasted filter is not renamed to a name an effect in a brush's art gives either. +#[test] +fn pasted_art_takes_no_name_the_documents_brushes_use() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift 2", "ids": [a.0]})); + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Fx"})); + run(&mut s, "edit.clear", json!({"ids": [a.0]})); + run(&mut s, "svgFilter.delete", json!({"name": "Shift 2"})); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + r#"{SHIFT_MORE}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let names: Vec = filter_effects(&s).into_iter().map(|(n, _)| n).collect(); + assert_eq!(names, ["Shift 3"]); +} + +/// A pasted filter at the size limit with a one-letter name, when every free name it could take is +/// in use, is refused rather than pasted under the name of the document's filter. +#[test] +fn a_paste_with_no_free_name_for_its_filter_is_refused() { + let mut s = session(); + for n in ('a'..='z').chain('0'..='9') { + run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + } + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + let (open, close) = (r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + let svg = format!( + r#"{big}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + let before = steps(&s); + assert!(err(&mut s, "edit.pasteInPlace", json!({})).contains("no free name")); + assert!(filter_effects(&s).is_empty(), "nothing pasted"); + assert_eq!(steps(&s), before); +} + /// A pasted filter whose name holds a tab, a line feed or a carriage return keeps those characters /// in its id under its new name. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 9c2a286b72..23349c348f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3764,7 +3764,8 @@ what every object that uses it draws, in the edit's one undo step, and an applic filter. Deleting a filter that objects use leaves their effects in place, still named, drawing nothing (their `xml` empty) until a filter of that name is defined or imported again. A pasted effect that names one of the document's filters but draws another definition keeps its own filter under a free name (`Soft 2`); pasted back into the document -it was copied from, it takes the document's filter. Opened from such an effect, Apply SVG Filter shows the +it was copied from, it takes the document's filter. The free name is one that no filter of the document and no effect in +its art, brushes or graphic styles uses; when no such name keeps the filter within 64 KB, the paste is refused. Opened from such an effect, Apply SVG Filter shows the name above the list, not selectable. `effect.apply {effect: "svg.filter", params: {xml}}` takes a filter directly and names it by its `id`; an `xml` that isn't one such filter is an error. `ui.svgFilterDialog {}` opens Apply SVG Filter (New, Edit, Delete, Import…), and from the Appearance panel it edits which filter an applied effect runs; From 5d7e2fa8e1ff7da1c63c513a89560e697640f232 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 06:13:29 -0800 Subject: [PATCH 15/49] MCP: ui.inspect leaves out paths outside the read roots Under --automation-read-root, ui.inspect returned the paths of documents still opening, the recent files, library panels' files and the folder preferences even when they lay outside the read roots. The reply now leaves them out, checked with file_access::check_read as ui.menu.list checks recent files and listed scripts: - opening and ui.recent_files keep only the readable paths, in order. - A library panel keeps its row without its path; a library loaded from outside the roots shows as loaded/ in its id and panel id, in ui.inspect and ui.docking. - The folder preferences in ui.engine_prefs and in an open Preferences dialog are left out; an empty folder and the scratch disk choices Startup and None stay. The app's own state is not changed. Without roots the replies are as before. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md. --- crates/ui-egui/src/control.rs | 109 +++++++++++++++++++++++-- crates/ui-egui/src/tests/automation.rs | 65 +++++++++++++++ docs/control-protocol.md | 2 +- docs/mcp.md | 7 ++ docs/target-app-parity.md | 1 + 5 files changed, 178 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7fd0d8edf9..9f27448924 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -249,8 +249,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // the layout. if let Some(fields) = ui.as_object_mut() { fields.remove("floating_panels"); + leave_out_unreadable_paths(fields); } - json!({ + let mut inspected = json!({ "scriptRunning": app.session.script_running(), "scriptReport": app.session.script_report(), "tool": app.session.tool_id(), @@ -294,8 +295,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "nativeMenuBar": app.services.native_menu.is_some(), // Saves and exports still being written in the background (Background Save / Export). "background": app.background.jobs.iter().filter(|j| !j.label.is_empty()).map(|j| j.label.as_str()).collect::>(), - // Documents still being read off the UI thread (File › Open, Open Recent): their paths. - "opening": app.opening.pending.iter().map(|p| p.path.as_str()).collect::>(), + // Documents still being read off the UI thread (File › Open, Open Recent): their paths, without + // the ones outside the read root. + "opening": app.opening.pending.iter().map(|p| p.path.as_str()).filter(|p| readable(p)).collect::>(), // Files still being read off the UI thread for Place (the Place dialog's OK, drops): their // names, in the order they are placed. "placing": app.placing.pending.iter().map(|p| p.name.as_str()).collect::>(), @@ -305,7 +307,100 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), - }) + }); + hide_library_ids(&mut inspected, &unreadable_library_ids(app)); + inspected +} + +/// The ids of the loaded libraries (`loaded/`) whose file the request may not read, each with +/// the id the reply shows instead: `loaded/`, as a library loaded without a path is named. +fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { + app.ui + .library_panels + .iter() + .filter_map(|panel| { + let path = panel.id.strip_prefix("loaded/")?; + let name = vectorcraft_engine::cmd::fileio::file_name(path); + (name != path && !readable(path)).then(|| (panel.id.clone(), format!("loaded/{name}"))) + }) + .collect() +} + +/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's +/// id, in its panel's id (`library:swatches:loaded/`) and in keys. +fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { + if ids.is_empty() { + return; + } + let hide = |s: &mut String| { + for (id, shown) in ids { + if s.contains(id.as_str()) { + *s = s.replace(id.as_str(), shown); + } + } + }; + match v { + Value::String(s) => hide(s), + Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)), + Value::Object(fields) => { + for (mut key, mut item) in std::mem::take(fields) { + hide(&mut key); + hide_library_ids(&mut item, ids); + fields.insert(key, item); + } + } + _ => {} + } +} + +/// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog. +const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; + +/// The scratch disk choices that are not paths. +const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; + +/// May the request being served read `path`? Under an automation read root (a control client's +/// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent +/// file or a listed script outside it. +fn readable(path: &str) -> bool { + vectorcraft_engine::file_access::check_read(path).is_ok() +} + +/// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files, +/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside +/// the read root. The UI state itself is not changed. +fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { + if let Some(Value::Array(files)) = ui.get_mut("recent_files") { + files.retain(|f| f.as_str().is_some_and(readable)); + } + if let Some(Value::Array(panels)) = ui.get_mut("library_panels") { + for panel in panels.iter_mut().filter_map(Value::as_object_mut) { + if panel.get("path").and_then(Value::as_str).is_some_and(|p| !readable(p)) { + panel.remove("path"); + } + } + } + if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") { + leave_out_unreadable_folders(prefs); + } + if let Some(Value::Object(dialog)) = ui.get_mut("dialog") + && dialog.get("kind").and_then(Value::as_str) == Some("preferences") + && let Some(Value::Object(fields)) = dialog.get_mut("fields") + { + leave_out_unreadable_folders(fields); + } +} + +/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read. An +/// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay. +fn leave_out_unreadable_folders(prefs: &mut serde_json::Map) { + for key in PREF_FOLDERS { + let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue }; + if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) { + continue; + } + prefs.remove(key); + } } fn key_from(name: &str) -> Option { @@ -348,7 +443,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", p.clone())), "ui.inspect" => ok(inspect(app, ctx)), - "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), + "ui.docking" => { + let mut docking = crate::panel_docking::inspect(app, ctx); + hide_library_ids(&mut docking, &unreadable_library_ids(app)); + ok(docking) + } "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 50c57db650..a369ee002e 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -360,6 +360,71 @@ fn recent_files_outside_the_read_root_are_not_listed_or_opened() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` leaves out the paths outside the read root that the person's work put in it: files +/// still opening, recent files, a library panel's file and folder preferences, saved and in the +/// Preferences dialog. +#[test] +fn ui_inspect_leaves_out_paths_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect"); + std::fs::copy(base.join("in/a.svg"), base.join("in/c.svg")).unwrap(); + let (a, b, c) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")), s(base.join("in/c.svg"))); + let svg = std::fs::read(&a).unwrap(); + for path in [&a, &b, &c] { + assert!(crate::opening::start(&mut app, path, svg.clone()).is_ok(), "{path} opens off the UI thread"); + } + app.ui.recent_files = vec![a.clone(), b.clone(), c.clone()]; + let panel = |id: &str, path: &str| -> crate::panels::library_panel::LibraryPanel { + serde_json::from_value(json!({"kind": "swatches", "id": id, "path": path})).unwrap() + }; + let theirs = format!("loaded/{b}"); + app.ui.library_panels = vec![panel("mine", &a), panel(&theirs, &b)]; + app.ui.open_panel = Some(format!("library:swatches:{theirs}")); + let (inside, outside) = (s(base.join("in")), s(base.join("outside"))); + app.ui.engine_prefs = json!({ + "fontsFolder": inside, "templatesFolder": outside, "recoveryFolder": outside, "scratchPrimary": "Startup", + "scratchSecondary": "../private", "pluginsFolder": "", + }); + app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({"fontsFolder": inside, "templatesFolder": outside}))); + + // The person sees every path. + let ctx = egui::Context::default(); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["opening"], json!([a, b, c])); + assert_eq!(person["ui"]["recent_files"], json!([a, b, c])); + assert_eq!(person["ui"]["library_panels"][1]["path"], b); + assert_eq!(person["ui"]["engine_prefs"]["templatesFolder"], outside); + + // The client sees the ones inside the read root, in the same order. + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!(r["result"]["opening"], json!([a, c]), "{r}"); + assert_eq!(ui["recent_files"], json!([a, c])); + assert_eq!(ui["library_panels"][0]["path"], a); + assert_eq!((ui["library_panels"][1]["id"].as_str(), ui["library_panels"][1].get("path")), (Some("loaded/b.svg"), None), "the panel stays"); + assert_eq!(ui["open_panel"], "library:swatches:loaded/b.svg"); + let prefs = ui["engine_prefs"].as_object().unwrap(); + assert_eq!( + (prefs["fontsFolder"].as_str(), prefs["scratchPrimary"].as_str(), prefs["pluginsFolder"].as_str()), + (Some(inside.as_str()), Some("Startup"), Some("")) + ); + assert!(["templatesFolder", "recoveryFolder", "scratchSecondary"].iter().all(|k| !prefs.contains_key(*k)), "{prefs:?}"); + let fields = ui["dialog"]["fields"].as_object().unwrap(); + assert_eq!((fields["fontsFolder"].as_str(), fields.get("templatesFolder")), (Some(inside.as_str()), None), "the Preferences dialog's copy too"); + let hidden = json!(outside).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let r = request(&mut app, &tx, "ui.docking", json!({})); + assert_eq!(r["result"]["flyout"], "library:swatches:loaded/b.svg", "{r}"); + assert_eq!(crate::panel_docking::inspect(&app, &ctx)["flyout"], json!(format!("library:swatches:{theirs}")), "the person's"); + assert_eq!(app.ui.recent_files, [a.clone(), b.clone(), c.clone()], "the UI state is not changed"); + assert_eq!(app.opening.pending.len(), 3, "and no open is canceled"); + + // A write root alone lets the client read none of them. + let write_only = AutomationRoots::new(&[], &[&base.join("out")]).unwrap(); + let listed = vectorcraft_engine::file_access::confine(write_only.as_ref(), || crate::control::inspect(&app, &ctx)); + assert_eq!((&listed["opening"], &listed["ui"]["recent_files"]), (&json!([]), &json!([])), "{listed}"); + let _ = std::fs::remove_dir_all(base); +} + /// Hidden logic-only ticks between the injection and its UI pass must not drain another step: /// one step per settled pass, not per hidden tick. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 96e5baffb3..04715474d0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -92,7 +92,7 @@ the two can still win that race. | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`). A key of `params` that the command's params description doesn't name is an error naming it and the nearest key the command takes, before anything runs (`unknown parameter `widht`; did you mean `width`?`); see [Parameter keys](mcp.md#parameter-keys) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | | `document.inspect` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | layer tree, selection, the key object (`key`, null without one; a key ruler guide as `keyGuide`), history, paint defaults; `depth` / `childLimit` slice the layer tree as `document.node {summary: true}` does; `ids`, `kind`, `limit` and `offset` list just some nodes as `nodes` (with `total`) instead of the whole tree, see [mcp.md](mcp.md). Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead | -| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) | +| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below), `opening` (the paths of the documents still being read). Under `--automation-read-root` the reply leaves out the paths outside the read roots (see `docs/mcp.md`) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. With the Artboard tool it lists the artboard entries (see `docs/mcp.md`), after selecting the artboard there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | diff --git a/docs/mcp.md b/docs/mcp.md index 9c2a286b72..595b4729d7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -115,6 +115,13 @@ the suite) keep the agent to the folders you give it: - `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. +- `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the + documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; + and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` + (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an + empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the + roots shows as `loaded/` in its id and its panel's id, in `ui_inspect` and in `ui.docking`. The other + entries keep their order, and the app's own state is not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c8fdfcb7e5..f030349bad 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -738,6 +738,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. + - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). From 6ae93b1222c0accf8bd91abb1821685ba81c83b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:12:15 -0800 Subject: [PATCH 16/49] 3D effects: Extrude & Bevel, Inflate and Rotate on a group keep each member's blending mode On a group, these effects drew every member's solids in Normal mode: only an opacity mask on a member or on a group inside the 3D group was applied to the solids it holds. A member's blending mode, and that of a group inside the 3D group, now apply the same way, to the solids of the members it holds together. Group Revolve already did this. --- crates/effects/src/solids.rs | 22 +++++++---- crates/effects/src/solids/tests_sources.rs | 43 ++++++++++++++++++++++ docs/three-d.md | 7 ++-- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 3c433d3445..95ef265cc9 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -275,23 +275,30 @@ impl Scope { } } +/// Does `scope` draw its members' faces other than plainly: does it have an opacity mask or a +/// blending mode other than Normal? +fn composites(scope: &Scope) -> bool { + scope.mask.is_some() || scope.blend != BlendMode::Normal +} + /// `surfaces`, each with the scopes of the member it was built from, in groups with the opacity -/// masks of those scopes, so each mask applies to the faces of the members it holds. The members' -/// opacity is already in their paint ([`Profile::opacity`]), so these groups are opaque. +/// masks and blending modes of those scopes, so each applies to the faces of the members it holds. +/// The members' opacity is already in their paint ([`Profile::opacity`]), so these groups are +/// opaque. fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc)>) -> Vec> { let masks: Vec<(Vec, Arc)> = surfaces .into_iter() .map(|(scopes, s)| { - let masks = scopes.iter().filter(|s| s.mask.is_some()).map(|s| Scope { opacity: 1.0, blend: BlendMode::Normal, ..s.clone() }).collect(); + let masks = scopes.iter().filter(|s| composites(s)).map(|s| Scope { opacity: 1.0, ..s.clone() }).collect(); (masks, s) }) .collect(); crate::revolve::scoped(id, masks.iter().map(|(scopes, s)| (scopes.as_slice(), s.clone())).collect(), 0) } -/// Does any of `shapes` sit in a scope with an opacity mask? +/// Does any of `shapes` sit in a scope with an opacity mask or a blending mode other than Normal? fn has_masks(shapes: &[Profile]) -> bool { - shapes.iter().any(|s| s.scopes.iter().any(|s| s.mask.is_some())) + shapes.iter().any(|s| s.scopes.iter().any(composites)) } /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint @@ -550,8 +557,9 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile /// each in its own paint. When every one is opaque with normal blending and no colored light makes /// a surface one composite or masks it, their faces are drawn in one back-to-front order (where /// depths tie, as for the caps turned toward the viewer, in the order of the appearance); -/// otherwise each solid is a surface of its own, in that order. A member's opacity mask, or that of -/// a group it is in, masks the surfaces of the members it holds ([`masked`]). +/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and +/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds +/// ([`masked`]). fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 2973b7113b..51c783452b 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -523,6 +523,49 @@ fn an_opacity_mask_on_a_member_or_a_group_in_a_three_d_group_masks_its_solids() } } +/// The leaf faces of `art`, and how many of them are drawn inside a group with blending mode `mode`. +fn faces_in_mode(art: &Node, mode: BlendMode) -> (usize, usize) { + fn walk(n: &Node, inside: bool, mode: BlendMode, out: &mut (usize, usize)) { + let inside = inside || (n.children().is_some() && n.blend == mode); + match n.children() { + Some(children) => children.iter().for_each(|c| walk(c, inside, mode, out)), + None => { + out.0 += 1; + out.1 += usize::from(inside); + } + } + } + let mut out = (0, 0); + walk(art, false, mode, &mut out); + out +} + +#[test] +fn a_blending_mode_on_a_member_or_a_group_in_a_three_d_group_applies_to_its_solids() { + let d = document(); + let mode = |mut n: Node, mode: BlendMode| { + n.blend = mode; + n + }; + let other = || Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0)); + let member = || rect(1, 100.0, 100.0, 150.0, 200.0, 2.0); + let cases = [ + ("a multiplied member", Node::group(NodeId(90), vec![Arc::new(mode(member(), BlendMode::Multiply)), other()])), + ( + "a member of a multiplied group", + Node::group(NodeId(90), vec![Arc::new(mode(Node::group(NodeId(80), vec![Arc::new(member())]), BlendMode::Multiply)), other()]), + ), + ]; + for (what, g) in cases { + for id in TYPES { + let g = with(g.clone(), id, json!({})); + let (faces, multiplied) = faces_in_mode(&art(&d, &g, id).expect("drawn"), BlendMode::Multiply); + assert!(multiplied > 0, "{id}: the faces of {what} are drawn in Multiply"); + assert!(multiplied < faces, "{id}: the other member's faces are not ({multiplied} of {faces})"); + } + } +} + #[test] fn resolved_objects_and_groups_in_a_three_d_group_keep_their_geometry_effects() { let mut d = document(); diff --git a/docs/three-d.md b/docs/three-d.md index e6dc279da3..635c1285e3 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -122,11 +122,12 @@ them all. The members stay editable: moving or editing one reshapes the solids. - Each member's own geometry effects shape it first, then those of each group inside the 3D group that holds it, applied to that group's members together; the 3D group's geometry effects before the 3D effect reshape the members, and those after it bend the result as a whole. The members' other effects, and the group's own fills and - strokes, are not drawn. A member's opacity applies to its solids; its blending mode does not. An opacity mask on + strokes, are not drawn. A member's opacity and blending mode apply to its solids, and the blending mode of a group + inside the 3D group applies to the solids of that group's members together. An opacity mask on a member masks its solids, and one on a group inside the 3D group masks the solids of that group's members together. The mask stays where it is on the page; it does not turn with the solids. A disabled mask is ignored. -- When a member, or a group inside the 3D group, has an opacity mask, each solid is drawn on its own, the backmost - member's first, as when a solid is transparent. +- When a member, or a group inside the 3D group, has an opacity mask or a blending mode other than Normal, each + solid is drawn on its own, the backmost member's first, as when a solid is transparent. - When every solid is opaque, all members' faces are drawn in one back-to-front order; otherwise each solid is drawn on its own, the backmost member's first. - Hidden members and guides are left out. A group holding a live object other than a blend (an envelope, a mesh, a From f60da17d3912d90b3cf51c680d6187472a64d363 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 06:48:04 -0800 Subject: [PATCH 17/49] Effects: a pasted SVG filter also takes a free capital letter when no lowercase name is free Filter names are case-sensitive, but a pasted filter at the size limit with a one-letter name only tried the letters a to z and the digits 0 to 9 as a new name. A paste was refused while every capital letter was still free. It now also tries A to Z. --- crates/engine/src/cmd/clipboard/resources.rs | 2 +- crates/engine/src/tests/svgfilter.rs | 28 +++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 534e0ec89f..918033a60b 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -1286,7 +1286,7 @@ impl Clipboard { ends.push(name.len()); let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty()); let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n))); - let single = ('a'..='z').chain('0'..='9').map(String::from).filter(|n| !taken.contains(n)); + let single = ('a'..='z').chain('0'..='9').chain('A'..='Z').map(String::from).filter(|n| !taken.contains(n)); let renamed = shorter.chain(single).find_map(|new| { let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new) .filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?; diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index 92f1e49873..ad4ac7e23b 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -324,10 +324,10 @@ fn pasted_art_takes_no_name_the_documents_brushes_use() { assert_eq!(names, ["Shift 3"]); } -/// A pasted filter at the size limit with a one-letter name, when every free name it could take is -/// in use, is refused rather than pasted under the name of the document's filter. +/// A pasted filter at the size limit with a one-letter name takes a free capital letter when every +/// lowercase letter and digit is in use. #[test] -fn a_paste_with_no_free_name_for_its_filter_is_refused() { +fn a_renamed_paste_takes_a_free_capital_letter() { let mut s = session(); for n in ('a'..='z').chain('0'..='9') { run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); @@ -339,6 +339,28 @@ fn a_paste_with_no_free_name_for_its_filter_is_refused() { r#"{big}"# ); run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, "A"); + assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok("A".to_string())); + assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); +} + +/// A pasted filter at the size limit with a one-letter name, when every free name it could take is +/// in use, is refused rather than pasted under the name of the document's filter. +#[test] +fn a_paste_with_no_free_name_for_its_filter_is_refused() { + let mut s = session(); + for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') { + run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + } + let (open, close) = (r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + let svg = format!( + r#"{big}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); let before = steps(&s); assert!(err(&mut s, "edit.pasteInPlace", json!({})).contains("no free name")); assert!(filter_effects(&s).is_empty(), "nothing pasted"); From ac8e39fa2ee65be1624faee8aaf7ec15e3d842a8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:24:23 -0800 Subject: [PATCH 18/49] Effects: a pasted SVG filter also takes a free punctuation character when no letter or digit is free A filter's id can be any character, so when every letter and digit is in use a pasted filter at the size limit with a one-letter name now tries the other printable ASCII characters (`!` to `~`) before the paste is refused. --- crates/engine/src/cmd/clipboard/resources.rs | 12 +++-- crates/engine/src/tests/svgfilter.rs | 57 +++++++++++++++++--- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 918033a60b..b1377d2fef 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -1239,7 +1239,7 @@ impl Clipboard { /// filter when pasted back into the document it came from, and elsewhere keeps its own under a /// name no filter or effect of the document gives, so editing or deleting the document's filter leaves it alone. When that name would /// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else - /// a free letter or digit; the filter's id is always its name. An error when no free name fits. + /// a free letter, digit or other printable ASCII character; the filter's id is always its name. An error when no free name fits. fn filter_links(&self, d: &Document, same_doc: bool) -> Result> { let mut found = BTreeSet::new(); for n in &self.nodes { @@ -1281,12 +1281,18 @@ impl Clipboard { } } // Free names made from the starts of the name, longest first, then free letters and - // digits: the first under which the filter still draws. + // digits, then other single printable ASCII characters: the first under which the + // filter still draws. let mut ends: Vec = name.char_indices().map(|(i, _)| i).skip(1).collect(); ends.push(name.len()); let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty()); let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n))); - let single = ('a'..='z').chain('0'..='9').chain('A'..='Z').map(String::from).filter(|n| !taken.contains(n)); + let single = ('a'..='z') + .chain('0'..='9') + .chain('A'..='Z') + .chain(('!'..='~').filter(|c| !c.is_ascii_alphanumeric())) + .map(String::from) + .filter(|n| !taken.contains(n)); let renamed = shorter.chain(single).find_map(|new| { let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new) .filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?; diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index ad4ac7e23b..10f0ad75ca 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -330,7 +330,7 @@ fn pasted_art_takes_no_name_the_documents_brushes_use() { fn a_renamed_paste_takes_a_free_capital_letter() { let mut s = session(); for n in ('a'..='z').chain('0'..='9') { - run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); } run(&mut s, "svgFilter.define", json!({"xml": r#""#})); let (open, close) = (r#""#); @@ -347,13 +347,24 @@ fn a_renamed_paste_takes_a_free_capital_letter() { assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); } -/// A pasted filter at the size limit with a one-letter name, when every free name it could take is -/// in use, is refused rather than pasted under the name of the document's filter. +/// A filter named `n`, with the characters a quoted attribute value cannot hold escaped. +fn one_char_filter(n: char) -> String { + let id = match n { + '"' => """.to_string(), + '&' => "&".to_string(), + '<' => "<".to_string(), + c => c.to_string(), + }; + format!(r#""#) +} + +/// A pasted filter at the size limit with a one-letter name takes a free punctuation character when +/// every letter and digit is in use. #[test] -fn a_paste_with_no_free_name_for_its_filter_is_refused() { +fn a_renamed_paste_takes_a_free_punctuation_character() { let mut s = session(); for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') { - run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); } let (open, close) = (r#""#); let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); @@ -361,9 +372,41 @@ fn a_paste_with_no_free_name_for_its_filter_is_refused() { r#"{big}"# ); run(&mut s, "clipboard.importSvg", json!({"svg": svg})); - let before = steps(&s); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, "!"); + assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok("!".to_string())); + assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); +} + +/// A pasted filter at the size limit with a one-letter name, when every free name it could take is +/// in use, is refused rather than pasted under the name of the document's filter. +#[test] +fn a_paste_with_no_free_name_for_its_filter_is_refused() { + let mut s = session(); + for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') { + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); + } + // A document holds at most 64 filters: the other one-character names are taken by effects + // whose filters were deleted. + for n in ('!'..='~').filter(|c| !c.is_ascii_alphanumeric()) { + let a = rect(&mut s, 10.0); + let name = run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)}))["name"].as_str().unwrap().to_string(); + assert_eq!(name, n.to_string()); + run(&mut s, "svgFilter.apply", json!({"name": name, "ids": [a.0]})); + run(&mut s, "svgFilter.delete", json!({"name": name})); + } + let (open, close) = (r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + let svg = format!( + r#"{big}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + let (before, effects) = (steps(&s), filter_effects(&s).len()); + assert_eq!(effects, 32); assert!(err(&mut s, "edit.pasteInPlace", json!({})).contains("no free name")); - assert!(filter_effects(&s).is_empty(), "nothing pasted"); + assert_eq!(filter_effects(&s).len(), effects, "nothing pasted"); assert_eq!(steps(&s), before); } From 478d86cb5fd175720335827ab254e1059564e3b4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:38:55 -0800 Subject: [PATCH 19/49] 3D effects: test that only the blended member's faces are drawn in its mode The blending-mode test for a member of a 3D group now checks that every face of the multiplied member, and no face of the other member, is drawn inside the Multiply group, telling the members apart by height. Before, it only checked that some faces were multiplied and some were not, so it also passed when the mode went to the wrong member. --- crates/effects/src/solids/tests_sources.rs | 25 ++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 51c783452b..b2f6daf1c6 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -523,19 +523,17 @@ fn an_opacity_mask_on_a_member_or_a_group_in_a_three_d_group_masks_its_solids() } } -/// The leaf faces of `art`, and how many of them are drawn inside a group with blending mode `mode`. -fn faces_in_mode(art: &Node, mode: BlendMode) -> (usize, usize) { - fn walk(n: &Node, inside: bool, mode: BlendMode, out: &mut (usize, usize)) { +/// The leaf faces of `art` that draw something, each with its visual bounds and whether it is drawn +/// inside a group with blending mode `mode`. +fn faces_in_mode(art: &Node, mode: BlendMode) -> Vec<(Rect, bool)> { + fn walk(n: &Node, inside: bool, mode: BlendMode, out: &mut Vec<(Rect, bool)>) { let inside = inside || (n.children().is_some() && n.blend == mode); match n.children() { Some(children) => children.iter().for_each(|c| walk(c, inside, mode, out)), - None => { - out.0 += 1; - out.1 += usize::from(inside); - } + None => out.extend(n.visual_bounds().map(|b| (b, inside))), } } - let mut out = (0, 0); + let mut out = Vec::new(); walk(art, false, mode, &mut out); out } @@ -559,9 +557,14 @@ fn a_blending_mode_on_a_member_or_a_group_in_a_three_d_group_applies_to_its_soli for (what, g) in cases { for id in TYPES { let g = with(g.clone(), id, json!({})); - let (faces, multiplied) = faces_in_mode(&art(&d, &g, id).expect("drawn"), BlendMode::Multiply); - assert!(multiplied > 0, "{id}: the faces of {what} are drawn in Multiply"); - assert!(multiplied < faces, "{id}: the other member's faces are not ({multiplied} of {faces})"); + // The member spans y 100 to 200 and the other member y 600 to 650, and every type keeps + // its faces near those heights (Revolve turns them around a vertical axis): a face whose + // center is above y 400 belongs to the member. + let (mine, others): (Vec<_>, Vec<_>) = + faces_in_mode(&art(&d, &g, id).expect("drawn"), BlendMode::Multiply).into_iter().partition(|(b, _)| b.center().y < 400.0); + assert!(!mine.is_empty() && !others.is_empty(), "{id}: both members draw faces ({} and {})", mine.len(), others.len()); + assert!(mine.iter().all(|(_, m)| *m), "{id}: every face of {what} is drawn in Multiply"); + assert!(others.iter().all(|(_, m)| !m), "{id}: no face of the other member is drawn in Multiply"); } } } From b44cbdb048720d4a3a9446d432b04420651cd070 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:39:47 -0800 Subject: [PATCH 20/49] Gradients: picking a part of a symbol instance leaves no gradient stop selected A stop selected on a whole dynamic symbol instance's gradient stayed selected after Direct Selection picked an object of that instance's art, because the instance stayed the first selected object. The Gradient panel kept the stop highlighted and the Gradient tool took Delete and the arrow keys for it, while every gradient edit is refused for a picked object. The stop's owner now records whether a part is picked, so the stop is not selected while one is, and is selected again when the whole instance is. --- crates/engine/src/cmd/gradient.rs | 15 +++++++++++--- crates/engine/src/tests/dynamic_symbols.rs | 23 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index c73a23d6c3..8a3485c499 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -80,19 +80,28 @@ fn select_stop(s: &mut Session, p: &Value) -> Result { } /// Whose gradient a selected stop belongs to: the active document, its first selected object -/// (None: the default paint), the proxy in front and the Appearance panel's active item. -/// Selecting other art, toggling the proxy or picking another item leaves no stop selected. +/// (None: the default paint), whether a part of a symbol instance's art is picked, the proxy in +/// front and the Appearance panel's active item. Selecting other art, picking a part of the +/// instance, toggling the proxy or picking another item leaves no stop selected. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct StopOwner { doc: Option, object: Option, + part: bool, fill: bool, item: Option, } impl StopOwner { pub(crate) fn of(s: &Session) -> Self { - Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active, item: s.appearance_item() } + let st = s.active(); + Self { + doc: s.active, + object: st.and_then(|d| d.selection.objects.first().copied()), + part: st.is_some_and(|d| d.selection.symbol_part.is_some()), + fill: s.fill_active, + item: s.appearance_item(), + } } } diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index eab2f30675..f4ae97bb14 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -903,6 +903,29 @@ fn gradients_and_patterns_on_a_picked_object_are_taken() { assert_eq!((undo_steps(&s), &s.paint.fill), (undo, &fill), "nothing changes"); } +/// A stop selected on the whole instance's gradient is not selected once a part of its art is +/// picked, so the Gradient tool does not take Delete for it, and is selected again when the whole +/// instance is. +#[test] +fn picking_a_part_leaves_no_gradient_stop_selected() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + s.execute("paint.setFill", &json!({"gradient": g})).unwrap(); + s.fill_active = true; + let view = ViewInfo::default(); + s.select_tool("gradient", view).unwrap(); + s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(); + assert_eq!(s.selected_stop(), Some(1)); + assert!(s.tool_claims_key(vectorcraft_tools::ToolKey::Delete, view)); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.selected_stop(), None); + assert!(!s.tool_claims_key(vectorcraft_tools::ToolKey::Delete, view)); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + assert_eq!(s.selected_stop(), Some(1)); +} + #[test] fn opacity_and_blend_on_a_picked_object_go_through_transparency_set() { let mut s = session(); From 774f7568dadc1e915be761a422051a470fd05645 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:40:36 -0800 Subject: [PATCH 21/49] MCP: ui.inspect hides every loaded library path outside the read roots Under --automation-read-root, ui.inspect and ui.docking hid a library's path only when its panel was in ui.library_panels. A library used as the Color Guide's limit, or named by a closed panel's place in ui.docking_hidden, a workspace layout or the docking layout, still showed its full loaded/ id. The ids to hide now come from every place the UI state refers to a library: the library panels, the Color Guide's limit, and the panel ids in the docking layout, closed panels, icon and collapsed lists and workspace layouts. Typed text such as a command palette query is not read as an id. Two hidden libraries with the same file name no longer share one id, which dropped one of them from an object keyed by panel id. They show as loaded/, loaded/ (2) and so on, in the order of their full ids, and a library has the same id in ui.inspect and ui.docking. Docs: docs/mcp.md. --- crates/ui-egui/src/control.rs | 98 +++++++++++++++++++--- crates/ui-egui/src/tests/automation.rs | 110 +++++++++++++++++++++++++ docs/mcp.md | 7 +- 3 files changed, 201 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9f27448924..b02c3b6e1d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -308,22 +308,95 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), }); - hide_library_ids(&mut inspected, &unreadable_library_ids(app)); + let ids = unreadable_library_ids(app); + hide_library_ids(&mut inspected, &ids); inspected } -/// The ids of the loaded libraries (`loaded/`) whose file the request may not read, each with -/// the id the reply shows instead: `loaded/`, as a library loaded without a path is named. +/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel +/// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels +/// shown as icons or collapsed, and the older floating panels' record. +const PANEL_FIELDS: [&str; 7] = + ["open_panel", "docking", "docking_hidden", "docking_hidden_groups", "docking_icons", "docking_collapsed", "floating_panels"]; + +/// The ids of the loaded libraries (`loaded/`) that the UI state refers to and whose file the +/// request may not read, each with the id a reply shows instead: `loaded/`, as a library +/// loaded without a path is named. The UI state refers to a library by its bare id in a library panel +/// and the Color Guide's limit, and by a panel id (`library::loaded/`) in the fields of +/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a +/// workspace's name) is not read. When another of those ids already shows that name, the next free one +/// of `loaded/ (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that +/// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id. fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { - app.ui - .library_panels - .iter() - .filter_map(|panel| { - let path = panel.id.strip_prefix("loaded/")?; - let name = vectorcraft_engine::cmd::fileio::file_name(path); - (name != path && !readable(path)).then(|| (panel.id.clone(), format!("loaded/{name}"))) + let mut ids = std::collections::BTreeSet::new(); + let ui = serde_json::to_value(&app.ui).unwrap_or_default(); + for field in PANEL_FIELDS { + collect_library_panel_ids(&ui[field], &mut ids); + } + for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { + for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { + collect_library_panel_ids(layout, &mut ids); + } + } + collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); + for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) { + add_library_id(id, &mut ids); + } + let mut hidden = Vec::new(); + let mut taken = std::collections::BTreeSet::new(); + for id in ids { + let path = &id["loaded/".len()..]; + let name = vectorcraft_engine::cmd::fileio::file_name(path); + if name != path && !readable(path) { + hidden.push((id, name)); + } else { + taken.insert(id); + } + } + let mut hidden = hidden + .into_iter() + .map(|(id, name)| { + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); + (id, shown) }) - .collect() + .collect::>(); + // The longest first, so that an id that begins another one does not replace part of it. + hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + hidden +} + +/// Adds `id` to `ids` when it is a loaded library's id (`loaded/`). +fn add_library_id(id: &str, ids: &mut std::collections::BTreeSet) { + if id.len() > "loaded/".len() && id.starts_with("loaded/") { + ids.insert(id.to_string()); + } +} + +/// Adds to `ids` the loaded library id of every library panel id (`library::loaded/`) +/// that `v` holds as a string or a key. +fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet) { + let add = |s: &str, ids: &mut std::collections::BTreeSet| { + if let Some((_, id)) = s.strip_prefix(crate::panels::library_panel::PREFIX).and_then(|rest| rest.split_once(':')) { + add_library_id(id, ids); + } + }; + match v { + Value::String(s) => add(s, ids), + Value::Array(items) => items.iter().for_each(|item| collect_library_panel_ids(item, ids)), + Value::Object(fields) => { + for (key, item) in fields { + add(key, ids); + collect_library_panel_ids(item, ids); + } + } + _ => {} + } } /// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's @@ -445,7 +518,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.inspect" => ok(inspect(app, ctx)), "ui.docking" => { let mut docking = crate::panel_docking::inspect(app, ctx); - hide_library_ids(&mut docking, &unreadable_library_ids(app)); + let ids = unreadable_library_ids(app); + hide_library_ids(&mut docking, &ids); ok(docking) } "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index a369ee002e..f725a0819c 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -425,6 +425,116 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// A closed panel's former place, as `ui.docking_hidden` keeps it. +fn hidden_place() -> craft_ui::docking::Location { + serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() +} + +/// `ui.inspect` hides the path of a library loaded from outside the read root wherever the reply holds +/// its id, also when its panel is closed: here only the Color Guide's limit and a closed panel's place +/// name it. +#[test] +fn ui_inspect_hides_a_closed_librarys_path_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-closed-lib"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.docking_hidden.insert(format!("library:swatches:{theirs}"), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!(ui["color_guide_limit"], "loaded/b.svg", "{r}"); + assert!(ui["docking_hidden"].as_object().unwrap().contains_key("library:swatches:loaded/b.svg"), "{r}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + assert_eq!(crate::control::inspect(&app, &egui::Context::default())["ui"]["color_guide_limit"], json!(theirs), "the person's"); + let _ = std::fs::remove_dir_all(base); +} + +/// Two libraries loaded from outside the read root with the same file name stay two in `ui.inspect`: +/// the second one shows as `loaded/ (2)`, the same in every place the reply names it. +#[test] +fn ui_inspect_keeps_two_hidden_libraries_with_one_file_name_apart() { + let (mut app, tx, base) = confined("inspect-same-name"); + let (x, y) = (format!("loaded/{}", s(base.join("outside/x/b.svg"))), format!("loaded/{}", s(base.join("outside/y/b.svg")))); + let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; + app.ui.library_panels = vec![panel(&x), panel(&y)]; + for id in [&x, &y] { + app.ui.docking_hidden.insert(format!("library:swatches:{id}"), hidden_place()); + } + app.ui.color_guide_limit = y.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + let ids: Vec<_> = ui["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/b.svg"), json!("loaded/b.svg (2)")], "{r}"); + let keys: Vec<_> = ui["docking_hidden"].as_object().unwrap().keys().cloned().collect(); + assert_eq!(keys, ["library:swatches:loaded/b.svg", "library:swatches:loaded/b.svg (2)"], "no entry is dropped: {r}"); + assert_eq!(ui["color_guide_limit"], "loaded/b.svg (2)", "the same id everywhere"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` and `ui.docking` show a hidden library under the same id, also when another hidden +/// library with the same file name is in only one of the replies (here as the Color Guide's limit). +#[test] +fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() { + let (mut app, tx, base) = confined("inspect-docking-same-id"); + let (x, y) = (format!("loaded/{}", s(base.join("outside/x/b.svg"))), format!("loaded/{}", s(base.join("outside/y/b.svg")))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": y})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.open_panel = Some(format!("library:swatches:{y}")); + app.ui.color_guide_limit = x.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!( + (ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()), + (Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")), + "{r}" + ); + let d = request(&mut app, &tx, "ui.docking", json!({})); + assert_eq!(d["result"]["flyout"], ui["open_panel"], "{d}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` rewrites only the ids of libraries the UI state refers to: a script's report line and +/// a command palette query that look like a library id, with no such library, stay as written, and +/// the query does not take the file name of a real library outside the root. +#[test] +fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { + let (mut app, tx, base) = confined("inspect-lookalike"); + let real = format!("loaded/{}", s(base.join("outside/x/d.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": real})).unwrap(); + app.ui.library_panels = vec![panel]; + let query = format!("library:swatches:loaded/{}", s(base.join("outside/d.svg"))); + app.ui.palette_query = query.clone(); + let text = format!("loaded/{}", s(base.join("outside/c.svg"))); + let source = format!("vc.log({})", json!(text)); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/d.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A client without automation roots sees every loaded library's full id in `ui.inspect`. +#[test] +fn ui_inspect_shows_full_library_ids_without_a_read_root() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let theirs = format!("loaded/{}", s(std::env::temp_dir().join("vc-ui-roots-unconfined").join("b.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": theirs})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.color_guide_limit = theirs.clone(); + app.ui.docking_hidden.insert(format!("library:swatches:{theirs}"), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!((&ui["library_panels"][0]["id"], &ui["color_guide_limit"]), (&json!(theirs), &json!(theirs)), "{r}"); + assert!(ui["docking_hidden"].as_object().unwrap().contains_key(&format!("library:swatches:{theirs}")), "{r}"); +} + /// Hidden logic-only ticks between the injection and its UI pass must not drain another step: /// one step per settled pass, not per hidden tick. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 595b4729d7..a3266538b1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -120,8 +120,11 @@ the suite) keep the agent to the folders you give it: and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the - roots shows as `loaded/` in its id and its panel's id, in `ui_inspect` and in `ui.docking`. The other - entries keep their order, and the app's own state is not changed. + roots shows as `loaded/` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the + Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is + closed. When another hidden library has the same file name, the libraries take `loaded/`, + `loaded/ (2)`, `(3)` and so on in the order of their full ids, and each one keeps its id in both + replies. The other entries keep their order, and the app's own state is not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice From b7a61969943bdf6ea5583ce46e18f7aa9d8804b5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:46:33 -0800 Subject: [PATCH 22/49] Scripts: File and Folder read files in the script's own folder Scripts get the File and Folder classes as built-ins of the script engine. A script run from a file can test whether files exist, read their properties, list folders with getFiles (a mask or a function), and open files to read them with read, readln, readch, seek and tell. - Reads stay inside the script's folder and, when automation read roots are set, inside them too. A script given as text reads no files. Network and device paths, and paths outside the folder by their names, are refused before anything is looked up. Links and junctions that lead out are refused, and getFiles leaves them out. - The encoding comes from the byte order mark, then the encoding the script set, then UTF-8 when the bytes are valid, then CP1252. - Limits: 8 MiB a file, 32 MiB a run, 256 files open at once, 2,048 getFiles entries and 65,536 names looked at, and paths of at most 32,767 characters. - Writing, the file dialogs and execute are not available yet: they return false or null and set error. The report's hostCallCounts gains fileCalls and fileExecutes. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/scripts.rs | 2 +- crates/engine/src/file_access.rs | 127 +++ crates/engine/src/script/files.rs | 1001 +++++++++++++++++++++ crates/engine/src/script/include.rs | 5 + crates/engine/src/script/mod.rs | 15 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/script_console.rs | 5 +- crates/engine/src/tests/script_io.rs | 398 ++++++++ crates/js/src/file_classes.rs | 864 ++++++++++++++++++ crates/js/src/host.rs | 2 +- crates/js/src/interp.rs | 4 + crates/js/src/lib.rs | 1 + crates/js/src/realm.rs | 14 + docs/mcp.md | 54 +- docs/target-app-parity.md | 1 + 15 files changed, 2489 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/script/files.rs create mode 100644 crates/engine/src/tests/script_io.rs create mode 100644 crates/js/src/file_classes.rs diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index cb97caffc8..13fa42f4d2 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 3f0a641e2e..27ebc9c40d 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -430,6 +430,133 @@ impl ScriptScope { } Ok((real, bytes)) } + + /// The first granted folder, as people read it (`None` for a script given as text). + pub(crate) fn folder(&self) -> Option { + self.roots.first().map(|r| crate::cmd::findfiles::plain(r.clone())) + } + + /// Does the absolute path `path` name a granted folder or a place inside one, by its names + /// alone, without regard to case? A path that does not is refused before the file system is + /// asked anything about it. + fn names_inside(&self, path: &Path) -> bool { + let lower = |c: Component| c.as_os_str().to_string_lossy().to_lowercase(); + path.is_absolute() + && self.roots.iter().any(|r| { + let root = crate::cmd::findfiles::plain(r.clone()); + let mut names = path.components(); + root.components().all(|c| names.next().is_some_and(|n| lower(n) == lower(c))) + }) + } + + /// The real path of the file or folder at `path`, which must be absolute: refused as + /// [`ScriptScope::read`] refuses a path, and also when its names alone place it outside every + /// granted folder, in which case the file system is not asked about it. + pub(crate) fn locate(&self, path: &Path) -> Result { + if self.roots.is_empty() || Self::refused_by_name(path) || !self.names_inside(path) { + return Err(ScriptRead::Refused); + } + probed(); + if check_read(&path.to_string_lossy()).is_err() { + return Err(ScriptRead::Refused); + } + let real = match std::fs::canonicalize(path) { + Ok(real) => real, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(if std::fs::symlink_metadata(path).is_ok() { ScriptRead::Refused } else { ScriptRead::Missing }); + } + Err(e) => return Err(ScriptRead::Failed(e.to_string())), + }; + if !self.roots.iter().any(|r| inside(&real, r)) { + return Err(ScriptRead::Refused); + } + Ok(real) + } + + /// What the file or folder at `path` is, located as [`ScriptScope::locate`] locates it. + pub(crate) fn stat(&self, path: &Path) -> Result { + let real = self.locate(path)?; + let meta = std::fs::metadata(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?; + let link = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()); + #[cfg(windows)] + let hidden = std::os::windows::fs::MetadataExt::file_attributes(&meta) & 2 != 0; + #[cfg(not(windows))] + let hidden = real.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')); + Ok(ScriptEntry { + dir: meta.is_dir(), + file: meta.is_file(), + len: meta.len(), + created: meta.created().ok(), + modified: meta.modified().ok(), + hidden, + readonly: meta.permissions().readonly(), + link, + }) + } + + /// The names in the folder at `path` that `keep` keeps, with whether each is a folder, in the + /// order of their names. A name whose links lead out of every granted folder, or out of the + /// automation read roots in force, is left out. More + /// than `max` names kept, or more than [`MAX_LISTED`] names looked at, is + /// [`ScriptRead::TooLarge`]. + pub(crate) fn list(&self, path: &Path, max: usize, keep: impl Fn(&str) -> bool) -> Result, ScriptRead> { + let real = self.locate(path)?; + let entries = std::fs::read_dir(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?; + let mut out = Vec::new(); + for (n, entry) in entries.enumerate() { + if n >= MAX_LISTED { + return Err(ScriptRead::TooLarge); + } + let Ok(entry) = entry else { continue }; + let name = entry.file_name().to_string_lossy().into_owned(); + if !keep(&name) { + continue; + } + let Ok(target) = std::fs::canonicalize(entry.path()) else { continue }; + if !self.roots.iter().any(|r| inside(&target, r)) || check_read(&entry.path().to_string_lossy()).is_err() { + continue; + } + if out.len() >= max { + return Err(ScriptRead::TooLarge); + } + out.push((name, target.is_dir())); + } + out.sort(); + Ok(out) + } +} + +/// The most names [`ScriptScope::list`] looks at in one folder. +pub(crate) const MAX_LISTED: usize = 65_536; + +/// A file or folder as [`ScriptScope::stat`] gives it. +#[derive(Clone, Debug)] +pub(crate) struct ScriptEntry { + pub dir: bool, + /// A regular file. + pub file: bool, + pub len: u64, + pub created: Option, + pub modified: Option, + pub hidden: bool, + pub readonly: bool, + /// The path itself is a symbolic link. + pub link: bool, +} + +thread_local! { + /// How many paths [`ScriptScope::locate`] has asked the file system about on this thread. + static PROBES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn probed() { + PROBES.with(|p| p.set(p.get().saturating_add(1))); +} + +/// How many paths [`ScriptScope::locate`] has asked the file system about on this thread. +#[cfg(test)] +pub(crate) fn script_probes() -> usize { + PROBES.with(std::cell::Cell::get) } #[cfg(test)] diff --git a/crates/engine/src/script/files.rs b/crates/engine/src/script/files.rs new file mode 100644 index 0000000000..3f53814992 --- /dev/null +++ b/crates/engine/src/script/files.rs @@ -0,0 +1,1001 @@ +//! `File` and `Folder`: a script's objects for files and folders, as the help pages describe them +//! (, +//! and +//! ). The classes are +//! built-ins of the realm ([`Realm::define_file_classes`]); the host functions here make every +//! path check and every read. +//! +//! An object holds an absolute path: a relative path is taken from `Folder.current`, `~` is the home +//! folder, `%xx` escapes are read as UTF-8, and on Windows both `/` and `\` separate names and +//! `/c/…` names drive C. Naming a path does not look anything up. +//! +//! VectorCraft's own rules: +//! +//! - A script reads only inside its [`ScriptScope`]: a script run from a file reads in that file's +//! folder and the folders below it, and a script given as text reads nothing. A path whose names +//! lie outside, a Windows network or device path (`\\host\share`, `\\?\`, `\\.\`), and a path that +//! leads out through a link are refused before or instead of opening anything: `exists` is false, +//! the dates are `null`, `getFiles` gives `null`, and `error` is [`ACCESS_DENIED`]. The automation +//! read roots apply as well, so a run with roots in force reads only inside both. +//! - `File(path)` without `new` gives a `Folder` for an existing folder, and `Folder(path)` gives a +//! `File` for an existing file, only where the path may be read. +//! - Opening reads the whole file, at most [`MAX_FILE_BYTES`], checked from its size before any of +//! it is read; a run reads at most [`MAX_RUN_BYTES`] in all and ends past that, and has at most +//! [`MAX_OPEN`] files open at once. +//! - The encoding of an opened file is the one its byte order mark gives (UTF-8, UTF-16LE or +//! UTF-16BE), otherwise the one the script set, otherwise UTF-8 when the bytes are valid UTF-8, +//! otherwise CP1252. `read`, `readln` and `readch` count characters and `seek` and `tell` bytes; +//! `readln` and `readch` take CR, LF, CRLF and LFCR as one line end, and `readch` gives a line end +//! as `"\n"`. A character the encoding cannot decode fails the read with [`UNDECODABLE`] and +//! leaves the position where it was. +//! - `getFiles` lists at most [`MAX_ENTRIES`] entries, sorted by name, and looks at most +//! [`crate::file_access::MAX_LISTED`] names; a mask matches `*` and `?` without regard to case, +//! in at most [`MAX_MASK_STEPS`] steps for one listing. +//! - A path longer than [`MAX_PATH_CHARS`] characters is refused before it is split into names. +//! - Writing (`open` with `w`, `e` or `a`, `write`, `copy`, `remove`, `rename`, `create` and the +//! property setters), the file and folder dialogs and `execute` are not available: they return +//! `false` or `null` and set `error`. `execute` is counted in the report's `fileExecutes`. +//! - In a wasm build every lookup fails with [`NO_WEB`]. + +use std::collections::HashMap; +use std::path::Path; +use std::rc::Rc; + +use vectorcraft_js::{Data, HostError, HostItem, Realm, RunError}; + +use super::{Host, bump}; +use crate::file_access::{ScriptRead, ScriptScope}; + +/// The most bytes of one file a script opens. +pub const MAX_FILE_BYTES: usize = 8 << 20; +/// The most bytes of files one run opens, all of them counted together. +pub const MAX_RUN_BYTES: usize = 32 << 20; +/// The most entries `getFiles` gives. +pub const MAX_ENTRIES: usize = 2048; +/// The most files a run has open at once. +pub const MAX_OPEN: usize = 256; +/// The most characters of a path a script gives. +pub const MAX_PATH_CHARS: usize = 32_767; +/// The most steps one `getFiles` listing spends matching its mask against names. +pub const MAX_MASK_STEPS: usize = 1 << 24; + +pub(crate) const ACCESS_DENIED: &str = "Script file access denied"; +pub(crate) const TOO_LARGE: &str = "Script file exceeds 8 MiB"; +pub(crate) const NOT_OPEN: &str = "Script file is not open"; +pub(crate) const UNDECODABLE: &str = "Script file cannot be decoded"; +pub(crate) const NOT_REGULAR: &str = "Script file is not a regular file"; +pub(crate) const NOT_FOUND: &str = "Script file does not exist"; +pub(crate) const READ_FAILED: &str = "Script file could not be read"; +pub(crate) const LISTING_TOO_LARGE: &str = "Script folder has more than 2,048 entries"; +pub(crate) const BAD_MODE: &str = "Script file open mode must be r, w, e or a"; +pub(crate) const NO_WRITES: &str = "Script file writing is not available"; +pub(crate) const NO_PICKER: &str = "Script file dialogs are not available"; +pub(crate) const NO_EXECUTE: &str = "Script files cannot be opened in another application"; +pub(crate) const NO_WEB: &str = "Script file access is unavailable on the web"; +pub(crate) const TOO_MANY_OPEN: &str = "Script has 256 files open"; +pub(crate) const PATH_TOO_LONG: &str = "Script file path exceeds 32,767 characters"; +pub(crate) const MASK_TOO_COSTLY: &str = "Script folder mask takes too many steps to match"; +const OVER_RUN_BYTES: &str = "the script opened more file data than one run may open (32 MiB)"; + +/// The script's file state: its scope, where `Folder.current` starts, the files it has open and how +/// much it has read. +pub(crate) struct Disk { + scope: ScriptScope, + home: String, + start: String, + open: HashMap, + next: u32, + temps: u32, + bytes: usize, +} + +impl Disk { + /// The state of a run whose own files are read in `scope`. + pub(crate) fn new(scope: &ScriptScope) -> Self { + let home = home_folder(); + let start = scope.folder().map_or_else(|| home.clone(), |p| p.to_string_lossy().into_owned()); + Self { scope: scope.clone(), home, start, open: HashMap::new(), next: 1, temps: 0, bytes: 0 } + } + + /// The path `text` names from the folder `base`, a platform path; `text`'s URI escapes are + /// decoded when `uri` is set. + fn pathname(&self, text: &str, base: &str, uri: bool) -> Pathname { + let root = Pathname::root(WINDOWS); + let home = Pathname::parse(&self.home, &root, &root, WINDOWS, false); + let base = Pathname::parse(base, &home, &home, WINDOWS, false); + Pathname::parse(text, &base, &home, WINDOWS, uri) + } + + /// The path `path` as the scope takes it, or why it is refused by its form alone. + fn checked<'a>(&self, path: &'a str) -> Result<&'a Path, &'static str> { + if cfg!(target_arch = "wasm32") { + return Err(NO_WEB); + } + if too_long(path) { + return Err(PATH_TOO_LONG); + } + let p = Path::new(path); + if !p.is_absolute() || (WINDOWS && (path.starts_with(r"\\") || path.starts_with("//"))) { + return Err(ACCESS_DENIED); + } + Ok(p) + } + + fn stat(&self, path: &str) -> Result, &'static str> { + match self.scope.stat(self.checked(path)?) { + Ok(e) => Ok(Some(e)), + Err(ScriptRead::Missing) => Ok(None), + Err(ScriptRead::Refused | ScriptRead::TooLarge) => Err(ACCESS_DENIED), + Err(ScriptRead::Failed(_)) => Err(READ_FAILED), + } + } + + fn open_file(&mut self, path: &str, encoding: &str) -> Result { + let fail = |m: &str| Err(HostError::Error(m.to_string())); + if self.open.len() >= MAX_OPEN { + return fail(TOO_MANY_OPEN); + } + let entry = match self.stat(path) { + Ok(Some(e)) => e, + Ok(None) => return fail(NOT_FOUND), + Err(m) => return fail(m), + }; + if !entry.file { + return fail(NOT_REGULAR); + } + if entry.len > MAX_FILE_BYTES as u64 { + return fail(TOO_LARGE); + } + if self.bytes.saturating_add(entry.len as usize) > MAX_RUN_BYTES { + return Err(HostError::Abort(OVER_RUN_BYTES.to_string())); + } + let bytes = match self.scope.read(Path::new(path), MAX_FILE_BYTES) { + Ok((_, bytes)) => bytes, + Err(ScriptRead::TooLarge) => return fail(TOO_LARGE), + Err(ScriptRead::Missing) => return fail(NOT_FOUND), + Err(ScriptRead::Refused) => return fail(ACCESS_DENIED), + Err(ScriptRead::Failed(_)) => return fail(READ_FAILED), + }; + if self.bytes.saturating_add(bytes.len()) > MAX_RUN_BYTES { + return Err(HostError::Abort(OVER_RUN_BYTES.to_string())); + } + self.bytes += bytes.len(); + let file = Open::new(bytes, encoding).ok_or_else(|| HostError::Error(UNDECODABLE.to_string()))?; + let id = self.next; + self.next = self.next.wrapping_add(1).max(1); + self.open.insert(id, file); + Ok(id) + } +} + +/// Whether paths are read as Windows reads them. +const WINDOWS: bool = cfg!(windows); + +/// Does `path` have more than [`MAX_PATH_CHARS`] characters? +fn too_long(path: &str) -> bool { + path.len() > MAX_PATH_CHARS && path.chars().nth(MAX_PATH_CHARS).is_some() +} + +/// The home folder, as the environment gives it. +fn home_folder() -> String { + let var = if WINDOWS { "USERPROFILE" } else { "HOME" }; + std::env::var(var).unwrap_or_else(|_| if WINDOWS { r"C:\".to_string() } else { "/".to_string() }) +} + +/// The folders `Folder.temp`, `Folder.desktop` and the others name, `null` where there is none. +fn locations(home: &str) -> Data { + let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + let join = |a: &str, b: &str| Path::new(a).join(b).to_string_lossy().into_owned(); + let exe = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_string_lossy().into_owned())); + let temp = std::env::temp_dir().to_string_lossy().into_owned(); + let (user_data, app_data, common, system, trash) = if WINDOWS { + (env("APPDATA"), env("PROGRAMDATA"), env("CommonProgramFiles"), env("SystemRoot"), None) + } else if cfg!(target_os = "macos") { + ( + Some(join(home, "Library/Application Support")), + Some("/Library/Application Support".to_string()), + Some("/Library/Application Support".to_string()), + Some("/System".to_string()), + Some(join(home, ".Trash")), + ) + } else { + let data = env("XDG_DATA_HOME").unwrap_or_else(|| join(home, ".local/share")); + (Some(data.clone()), Some("/usr/share".to_string()), Some("/usr/share".to_string()), Some("/".to_string()), Some(join(&data, "Trash"))) + }; + let s = |v: Option| v.map_or(Data::Null, Data::String); + let web = cfg!(target_arch = "wasm32"); + let places = vec![ + ("appData", app_data), + ("appPackage", exe.clone()), + ("commonFiles", common), + ("desktop", Some(join(home, "Desktop"))), + ("myDocuments", Some(join(home, "Documents"))), + ("startup", exe), + ("system", system), + ("temp", Some(temp)), + ("trash", trash), + ("userData", user_data), + ]; + Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), if web { Data::Null } else { s(v) })).collect()) +} + +/// The start of an absolute path. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Root { + /// `/`. + Unix, + /// A Windows drive letter, in upper case. + Drive(char), + /// A Windows share: `\\host\share`. + Share(String, String), + /// A Windows `\\?\` or `\\.\` path, kept as given. + Device(String), +} + +/// An absolute path: its root and its names, with `.` and `..` resolved. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Pathname { + root: Root, + names: Vec, +} + +impl Pathname { + fn root(windows: bool) -> Self { + Self { root: if windows { Root::Drive('C') } else { Root::Unix }, names: Vec::new() } + } + + /// The path `text` names, relative ones taken from `base` and `~` from `home`, with its URI + /// escapes decoded when `uri` is set. + fn parse(text: &str, base: &Pathname, home: &Pathname, windows: bool, uri: bool) -> Self { + let decoded = if uri { decode(text) } else { text.to_string() }; + let t = if windows { decoded.replace('\\', "/") } else { decoded }; + let (start, rest): (Pathname, &str) = if windows && (t.starts_with("//?/") || t.starts_with("//./")) { + return Self { root: Root::Device(t.replace('/', "\\")), names: Vec::new() }; + } else if let Some(r) = t.strip_prefix("//").filter(|_| windows) { + let mut parts = r.splitn(3, '/'); + let host = parts.next().unwrap_or_default().to_string(); + let share = parts.next().unwrap_or_default().to_string(); + (Self { root: Root::Share(host, share), names: Vec::new() }, parts.next().unwrap_or_default()) + } else if windows && t.len() >= 2 && t.as_bytes()[0].is_ascii_alphabetic() && t.as_bytes()[1] == b':' { + (Self { root: Root::Drive(t.as_bytes()[0].to_ascii_uppercase() as char), names: Vec::new() }, t.get(2..).unwrap_or_default()) + } else if let Some(r) = t.strip_prefix('/') { + let (first, after) = r.split_once('/').unwrap_or((r, "")); + if windows && first.len() == 1 && first.as_bytes()[0].is_ascii_alphabetic() { + (Self { root: Root::Drive(first.as_bytes()[0].to_ascii_uppercase() as char), names: Vec::new() }, after) + } else if windows { + let root = match &base.root { + Root::Drive(_) | Root::Share(..) => base.root.clone(), + _ => Root::Drive('C'), + }; + (Self { root, names: Vec::new() }, r) + } else { + (Self { root: Root::Unix, names: Vec::new() }, r) + } + } else if t == "~" || t.starts_with("~/") { + (home.clone(), t.get(1..).unwrap_or_default()) + } else { + (base.clone(), t.as_str()) + }; + let mut out = start; + for name in rest.split('/') { + match name { + "" | "." => {} + ".." => { + out.names.pop(); + } + n => out.names.push(n.to_string()), + } + } + out + } + + /// The path as the platform writes it. + fn fs(&self) -> String { + match &self.root { + Root::Unix => format!("/{}", self.names.join("/")), + Root::Drive(c) => format!("{c}:\\{}", self.names.join("\\")), + Root::Share(host, share) => self.names.iter().fold(format!(r"\\{host}\{share}"), |s, n| format!("{s}\\{n}")), + Root::Device(raw) => raw.clone(), + } + } + + /// The path in URI notation, each name escaped. + fn uri(&self) -> String { + let start = match &self.root { + Root::Unix => String::new(), + Root::Drive(c) => format!("/{}", c.to_ascii_lowercase()), + Root::Share(host, share) => format!("//{}/{}", encode(host), encode(share)), + Root::Device(raw) => return raw.replace('\\', "/"), + }; + let names: String = self.names.iter().map(|n| format!("/{}", encode(n))).collect(); + if start.is_empty() && names.is_empty() { "/".to_string() } else { start + &names } + } + + fn parent(&self) -> Option { + let mut p = self.clone(); + p.names.pop().map(|_| p) + } + + /// `self` relative to the folder `base`, in URI notation; the whole URI when they have different + /// roots. + fn relative_to(&self, base: &Pathname, windows: bool) -> String { + if self.root != base.root { + return self.uri(); + } + let same = |a: &String, b: &String| if windows { a.to_lowercase() == b.to_lowercase() } else { a == b }; + let common = self.names.iter().zip(&base.names).take_while(|(a, b)| same(a, b)).count(); + let mut parts: Vec = vec!["..".to_string(); base.names.len() - common]; + parts.extend(self.names.iter().skip(common).map(|n| encode(n))); + if parts.is_empty() { ".".to_string() } else { parts.join("/") } + } + + /// The parts of `File` and `Folder` objects that come from the path alone. + fn data(&self) -> Data { + let s = |v: String| Data::String(v); + Data::Object(vec![ + ("fs".into(), s(self.fs())), + ("uri".into(), s(self.uri())), + ("name".into(), s(self.names.last().map(|n| encode(n)).unwrap_or_default())), + ("display".into(), s(self.names.last().cloned().unwrap_or_default())), + ("path".into(), s(self.parent().map(|p| p.uri()).unwrap_or_default())), + ("parent".into(), self.parent().map_or(Data::Null, |p| Data::String(p.fs()))), + ]) + } +} + +/// `s` with every character but letters, digits and `-_.!~*'()` written as `%xx` escapes of its +/// UTF-8 bytes (RFC 2396). +fn encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// `s` with its `%xx` escapes read as UTF-8 bytes; `s` as it is when they do not make UTF-8. +fn decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + let hex = |c: u8| (c as char).to_digit(16); + match (b[i], b.get(i + 1).copied().and_then(hex), b.get(i + 2).copied().and_then(hex)) { + (b'%', Some(h), Some(l)) => { + out.push((h * 16 + l) as u8); + i += 3; + } + (c, ..) => { + out.push(c); + i += 1; + } + } + } + String::from_utf8(out).unwrap_or_else(|_| s.to_string()) +} + +/// A `getFiles` mask, where `*` stands for any run of characters and `?` for one, without regard +/// to case, read once for a whole listing. +struct Mask { + /// The mask in lower case, each run of `*` written as one. + chars: Vec, + /// How many of its characters are not `*`: a name with fewer characters does not match. + fixed: usize, +} + +impl Mask { + fn new(mask: &str) -> Self { + let mut chars: Vec = Vec::new(); + for c in mask.to_lowercase().chars() { + if !(c == '*' && chars.last() == Some(&'*')) { + chars.push(c); + } + } + let fixed = chars.iter().filter(|c| **c != '*').count(); + Self { chars, fixed } + } + + /// Does `name` match, taking a step from `steps` for each character compared? `None` when the + /// steps run out first. + fn matches(&self, name: &str, steps: &mut usize) -> Option { + let n: Vec = name.to_lowercase().chars().collect(); + if n.len() < self.fixed { + return Some(false); + } + matched(&self.chars, &n, steps) + } +} + +/// Does `n` match the lower-case mask `m`? `None` when `steps` run out first. +fn matched(m: &[char], n: &[char], steps: &mut usize) -> Option { + let (mut i, mut j, mut star, mut mark) = (0usize, 0usize, None, 0usize); + while j < n.len() { + *steps = steps.checked_sub(1)?; + match m.get(i) { + Some('*') => { + star = Some(i); + mark = j; + i += 1; + } + Some(c) if *c == '?' || *c == n[j] => { + i += 1; + j += 1; + } + _ => match star { + Some(s) => { + i = s + 1; + mark += 1; + j = mark; + } + None => return Some(false), + }, + } + } + Some(m[i..].iter().all(|c| *c == '*')) +} + +/// A character encoding an opened file is read in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Encoding { + Utf8, + Ascii, + Binary, + Latin1, + Cp1252, + Utf16Le, + Utf16Be, +} + +/// The characters CP1252 gives bytes 0x80 to 0x9F; the five it leaves undefined stand for +/// themselves. +const CP1252_HIGH: [u16; 32] = [ + 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018, + 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, +]; + +impl Encoding { + fn parse(name: &str) -> Option { + Some(match name.trim().to_ascii_uppercase().as_str() { + "UTF-8" | "UTF8" => Self::Utf8, + "ASCII" | "US-ASCII" => Self::Ascii, + "BINARY" => Self::Binary, + "ISO-8859-1" | "ISO8859-1" | "LATIN1" | "LATIN-1" => Self::Latin1, + "CP1252" | "WINDOWS-1252" => Self::Cp1252, + "UTF-16LE" | "UTF16LE" => Self::Utf16Le, + "UTF-16" | "UTF16" | "UTF-16BE" | "UTF16BE" | "UCS-2" => Self::Utf16Be, + _ => return None, + }) + } + + fn name(self) -> &'static str { + match self { + Self::Utf8 => "UTF-8", + Self::Ascii => "ASCII", + Self::Binary => "BINARY", + Self::Latin1 => "ISO-8859-1", + Self::Cp1252 => "CP1252", + Self::Utf16Le => "UTF-16LE", + Self::Utf16Be => "UTF-16BE", + } + } +} + +/// An opened file: its bytes, the byte position and the encoding it is read in. +struct Open { + bytes: Vec, + pos: usize, + encoding: Encoding, + name: String, +} + +impl Open { + /// `bytes` opened with the encoding `asked` (empty: none asked); `None` when `asked` is not an + /// encoding and the file has no byte order mark. + fn new(bytes: Vec, asked: &str) -> Option { + let (encoding, pos) = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + (Encoding::Utf8, 3) + } else if bytes.starts_with(&[0xFF, 0xFE]) { + (Encoding::Utf16Le, 2) + } else if bytes.starts_with(&[0xFE, 0xFF]) { + (Encoding::Utf16Be, 2) + } else if !asked.is_empty() { + (Encoding::parse(asked)?, 0) + } else if std::str::from_utf8(&bytes).is_ok() { + (Encoding::Utf8, 0) + } else { + (Encoding::Cp1252, 0) + }; + Some(Self { bytes, pos, encoding, name: encoding.name().to_string() }) + } + + /// The character at byte `at` and its length in bytes; `Ok(None)` at the end, `Err` where the + /// encoding cannot decode it. + fn char_at(&self, at: usize) -> Result, ()> { + let Some(&b) = self.bytes.get(at) else { return Ok(None) }; + let encoding = self.encoding; + let unit = |i: usize| -> Option { + let pair = self.bytes.get(i..i + 2)?; + Some(if encoding == Encoding::Utf16Le { u16::from_le_bytes([pair[0], pair[1]]) } else { u16::from_be_bytes([pair[0], pair[1]]) }) + }; + let c = match encoding { + Encoding::Ascii if b < 0x80 => (b as char, 1), + Encoding::Ascii => return Err(()), + Encoding::Binary | Encoding::Latin1 => (b as char, 1), + Encoding::Cp1252 => { + let code = if (0x80..0xA0).contains(&b) { u32::from(CP1252_HIGH[usize::from(b - 0x80)]) } else { u32::from(b) }; + (char::from_u32(code).ok_or(())?, 1) + } + Encoding::Utf8 => { + let len = match b { + 0x00..=0x7F => 1, + 0xC2..=0xDF => 2, + 0xE0..=0xEF => 3, + 0xF0..=0xF4 => 4, + _ => return Err(()), + }; + let s = self.bytes.get(at..at + len).and_then(|s| std::str::from_utf8(s).ok()).ok_or(())?; + (s.chars().next().ok_or(())?, len) + } + Encoding::Utf16Le | Encoding::Utf16Be => { + let first = unit(at).ok_or(())?; + if (0xD800..0xDC00).contains(&first) { + let second = unit(at + 2).filter(|u| (0xDC00..0xE000).contains(u)).ok_or(())?; + let code = 0x10000 + ((u32::from(first) - 0xD800) << 10) + (u32::from(second) - 0xDC00); + (char::from_u32(code).ok_or(())?, 4) + } else { + (char::from_u32(u32::from(first)).ok_or(())?, 2) + } + } + }; + Ok(Some(c)) + } + + /// Up to `max` characters from the position (all of them for `None`). + fn read(&mut self, max: Option) -> Result { + let (mut at, mut out) = (self.pos, String::new()); + let mut n = 0; + while max.is_none_or(|m| n < m) { + let Some((c, len)) = self.char_at(at)? else { break }; + out.push(c); + at += len; + n += 1; + } + self.pos = at; + Ok(out) + } + + /// The position after a line end that began with `c` and continues at `at` (CR, LF, CRLF or + /// LFCR). A character there that cannot be decoded is left for the next read. + fn past_line_end(&self, c: char, at: usize) -> usize { + let other = if c == '\r' { '\n' } else { '\r' }; + match self.char_at(at) { + Ok(Some((n, len))) if n == other => at + len, + _ => at, + } + } + + /// The characters up to the next line end, which is passed over. + fn read_line(&mut self) -> Result { + let (mut at, mut out) = (self.pos, String::new()); + while let Some((c, len)) = self.char_at(at)? { + at += len; + if c == '\r' || c == '\n' { + at = self.past_line_end(c, at); + break; + } + out.push(c); + } + self.pos = at; + Ok(out) + } + + /// The next character; a line end is one `"\n"`. + fn read_char(&mut self) -> Result { + let Some((c, len)) = self.char_at(self.pos)? else { return Ok(String::new()) }; + let mut at = self.pos + len; + let c = if c == '\r' || c == '\n' { + at = self.past_line_end(c, at); + '\n' + } else { + c + }; + self.pos = at; + Ok(c.to_string()) + } +} + +/// `{text}` for a read that worked, `{error}` for one that did not. +fn text_result(r: Result) -> Data { + match r { + Ok(t) => Data::Object(vec![("text".into(), Data::String(t))]), + Err(()) => error(UNDECODABLE), + } +} + +fn error(message: &str) -> Data { + Data::Object(vec![("error".into(), Data::String(message.to_string()))]) +} + +fn arg_text(a: &[Data], i: usize) -> String { + match a.get(i) { + Some(Data::String(s)) => s.clone(), + _ => String::new(), + } +} + +fn arg_number(a: &[Data], i: usize) -> Option { + match a.get(i) { + Some(Data::Number(n)) => Some(*n), + _ => None, + } +} + +fn millis(t: Option) -> Data { + t.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(Data::Null, |d| Data::Number(d.as_millis() as f64)) +} + +/// One host function of `__vcFiles`, counted and charged as a host call. +fn call(host: &Rc, f: fn(&Host, &[Data]) -> Result) -> HostItem { + let h = host.clone(); + HostItem::Function(Rc::new(move |a| { + bump(&h.counts.file_calls); + let r = f(&h, &a)?; + h.charge_call(super::data_size(&r).saturating_add(a.iter().map(super::data_size).sum()))?; + Ok(r) + })) +} + +fn handle(a: &[Data]) -> u32 { + arg_number(a, 0).filter(|n| n.is_finite() && *n >= 0.0 && *n <= f64::from(u32::MAX)).map_or(0, |n| n as u32) +} + +fn with_open(host: &Host, a: &[Data], f: impl FnOnce(&mut Open) -> Data) -> Data { + match host.disk.borrow_mut().open.get_mut(&handle(a)) { + Some(file) => f(file), + None => error(NOT_OPEN), + } +} + +/// The first two arguments as path text, refused with a `RangeError` when either is too long. +fn two_paths(a: &[Data]) -> Result<(String, String), HostError> { + let (path, base) = (arg_text(a, 0), arg_text(a, 1)); + if too_long(&path) || too_long(&base) { + return Err(HostError::Range(PATH_TOO_LONG.to_string())); + } + Ok((path, base)) +} + +fn parse(host: &Host, a: &[Data]) -> Result { + let (path, base) = two_paths(a)?; + let platform = matches!(a.get(2), Some(Data::Bool(true))); + let disk = host.disk.borrow(); + Ok(disk.pathname(&path, &base, !platform).data()) +} + +/// The platform path `a[0]` relative to the platform path `a[1]`. +fn relative(host: &Host, a: &[Data]) -> Result { + let (path, base) = two_paths(a)?; + let disk = host.disk.borrow(); + let base = disk.pathname(&base, &disk.start, false); + Ok(Data::String(disk.pathname(&path, &disk.start, false).relative_to(&base, WINDOWS))) +} + +fn stat(host: &Host, a: &[Data]) -> Result { + Ok(match host.disk.borrow().stat(&arg_text(a, 0)) { + Err(m) => error(m), + Ok(None) => Data::Object(vec![("exists".into(), Data::Bool(false))]), + Ok(Some(e)) => Data::Object(vec![ + ("exists".into(), Data::Bool(true)), + ("dir".into(), Data::Bool(e.dir)), + ("file".into(), Data::Bool(e.file)), + ("length".into(), Data::Number(e.len as f64)), + ("created".into(), millis(e.created)), + ("modified".into(), millis(e.modified)), + ("hidden".into(), Data::Bool(e.hidden)), + ("readonly".into(), Data::Bool(e.readonly)), + ("alias".into(), Data::Bool(e.link)), + ]), + }) +} + +fn open(host: &Host, a: &[Data]) -> Result { + match host.disk.borrow_mut().open_file(&arg_text(a, 0), &arg_text(a, 1)) { + Ok(id) => Ok(Data::Object(vec![("id".into(), Data::Number(f64::from(id)))])), + Err(HostError::Error(m)) => Ok(error(&m)), + Err(e) => Err(e), + } +} + +fn close(host: &Host, a: &[Data]) -> Result { + Ok(Data::Bool(host.disk.borrow_mut().open.remove(&handle(a)).is_some())) +} + +fn read(host: &Host, a: &[Data]) -> Result { + let max = arg_number(a, 1).filter(|n| *n >= 0.0).map(|n| n as usize); + Ok(with_open(host, a, |f| text_result(f.read(max)))) +} + +fn readln(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| text_result(f.read_line()))) +} + +fn readch(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| text_result(f.read_char()))) +} + +fn seek(host: &Host, a: &[Data]) -> Result { + let (Some(pos), Some(mode)) = (arg_number(a, 1), arg_number(a, 2)) else { return Err(HostError::Type(BAD_SEEK.into())) }; + Ok(with_open(host, a, |f| { + let base = match mode as u8 { + 1 => f.pos as f64, + 2 => f.bytes.len() as f64, + _ => 0.0, + }; + let to = base + pos.trunc(); + if !(0.0..=f.bytes.len() as f64).contains(&to) { + return Data::Bool(false); + } + f.pos = to as usize; + Data::Bool(true) + })) +} + +const BAD_SEEK: &str = "File.seek: takes a position and a mode"; + +fn tell(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| Data::Number(f.pos as f64))) +} + +fn eof(host: &Host, a: &[Data]) -> Result { + Ok(Data::Bool(host.disk.borrow().open.get(&handle(a)).is_none_or(|f| f.pos >= f.bytes.len()))) +} + +fn encoding(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| Data::String(f.name.clone()))) +} + +fn set_encoding(host: &Host, a: &[Data]) -> Result { + let name = arg_text(a, 1); + Ok(with_open(host, a, |f| match Encoding::parse(&name) { + Some(e) => { + f.encoding = e; + f.name = e.name().to_string(); + Data::Object(vec![]) + } + None => error(UNDECODABLE), + })) +} + +fn list(host: &Host, a: &[Data]) -> Result { + let disk = host.disk.borrow(); + let path = arg_text(a, 0); + let mask = Mask::new(&arg_text(a, 1)); + let checked = match disk.checked(&path) { + Ok(p) => p, + Err(m) => return Ok(error(m)), + }; + let steps = std::cell::Cell::new(MAX_MASK_STEPS); + let spent = std::cell::Cell::new(false); + let keep = |name: &str| { + let mut left = steps.get(); + let kept = mask.matches(name, &mut left); + steps.set(left); + spent.set(spent.get() || kept.is_none()); + kept.unwrap_or(false) + }; + let listed = disk.scope.list(checked, MAX_ENTRIES, keep); + if spent.get() { + return Ok(error(MASK_TOO_COSTLY)); + } + let found = match listed { + Ok(found) => found, + Err(ScriptRead::TooLarge) => return Ok(error(LISTING_TOO_LARGE)), + Err(ScriptRead::Missing) => return Ok(error(NOT_FOUND)), + Err(ScriptRead::Refused) => return Ok(error(ACCESS_DENIED)), + Err(ScriptRead::Failed(_)) => return Ok(error(READ_FAILED)), + }; + let entries = found + .into_iter() + .map(|(name, dir)| { + let fs = Path::new(&path).join(&name).to_string_lossy().into_owned(); + Data::Object(vec![("fs".into(), Data::String(fs)), ("dir".into(), Data::Bool(dir))]) + }) + .collect(); + Ok(Data::Object(vec![("entries".into(), Data::Array(entries))])) +} + +fn resolve(host: &Host, a: &[Data]) -> Result { + let disk = host.disk.borrow(); + let path = arg_text(a, 0); + let target = match disk.stat(&path) { + Err(m) => return Ok(error(m)), + Ok(Some(e)) if e.link => disk.checked(&path).ok().and_then(|p| disk.scope.locate(p).ok()), + Ok(_) => None, + }; + Ok(match target { + Some(real) => { + let dir = real.is_dir(); + let fs = crate::cmd::findfiles::plain(real).to_string_lossy().into_owned(); + Data::Object(vec![("fs".into(), Data::String(fs)), ("dir".into(), Data::Bool(dir))]) + } + None => Data::Object(vec![("fs".into(), Data::Null)]), + }) +} + +fn execute(host: &Host, _: &[Data]) -> Result { + bump(&host.counts.file_executes); + Ok(error(NO_EXECUTE)) +} + +fn temp_name(host: &Host, _: &[Data]) -> Result { + let mut disk = host.disk.borrow_mut(); + disk.temps += 1; + let name = format!("vc{:05}.tmp", disk.temps); + Ok(Data::String(std::env::temp_dir().join(name).to_string_lossy().into_owned())) +} + +/// Adds `File` and `Folder` to the realm. +pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError> { + let (start, home) = { + let disk = host.disk.borrow(); + (disk.pathname(&disk.start, &disk.start, false).fs(), disk.home.clone()) + }; + let value = |s: &str| HostItem::Value(Data::String(s.to_string())); + let text = HostItem::Object(vec![ + ("notOpen".into(), value(NOT_OPEN)), + ("noWrites".into(), value(NO_WRITES)), + ("noPicker".into(), value(NO_PICKER)), + ("badMode".into(), value(BAD_MODE)), + ]); + let pure = |f: fn(&str) -> Data| HostItem::Function(Rc::new(move |a| Ok(f(&arg_text(&a, 0))))); + let system = if WINDOWS { + "Windows" + } else if cfg!(target_os = "macos") { + "Macintosh" + } else { + "Unix" + }; + let items = vec![ + ("text".into(), text), + ("system".into(), value(system)), + ("lineFeed".into(), value(if WINDOWS { "Windows" } else { "Unix" })), + ("current".into(), HostItem::Function(Rc::new(move |_| Ok(Data::String(start.clone()))))), + ("locations".into(), HostItem::Function(Rc::new(move |_| Ok(locations(&home))))), + ("encode".into(), pure(|s| Data::String(encode(s)))), + ("decode".into(), pure(|s| Data::String(decode(s)))), + ("encodingAvailable".into(), pure(|s| Data::Bool(Encoding::parse(s).is_some()))), + ("parse".into(), call(host, parse)), + ("relative".into(), call(host, relative)), + ("tempName".into(), call(host, temp_name)), + ("stat".into(), call(host, stat)), + ("open".into(), call(host, open)), + ("close".into(), call(host, close)), + ("read".into(), call(host, read)), + ("readln".into(), call(host, readln)), + ("readch".into(), call(host, readch)), + ("seek".into(), call(host, seek)), + ("tell".into(), call(host, tell)), + ("eof".into(), call(host, eof)), + ("encoding".into(), call(host, encoding)), + ("setEncoding".into(), call(host, set_encoding)), + ("list".into(), call(host, list)), + ("resolve".into(), call(host, resolve)), + ("execute".into(), call(host, execute)), + ]; + realm.define_file_classes(items) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(text: &str, base: &str, windows: bool) -> Pathname { + let root = Pathname::root(windows); + let home = Pathname::parse(if windows { r"C:\Users\me" } else { "/home/me" }, &root, &root, windows, false); + let base = Pathname::parse(base, &home, &home, windows, false); + Pathname::parse(text, &base, &home, windows, true) + } + + #[test] + fn windows_paths_take_both_separators_drives_and_uri_names() { + let f = p(r"C:\art\one two.txt", r"C:\work", true); + assert_eq!((f.fs(), f.uri()), (r"C:\art\one two.txt".to_string(), "/c/art/one%20two.txt".to_string())); + assert_eq!(p("/d/x/y.txt", r"C:\work", true).fs(), r"D:\x\y.txt"); + assert_eq!(p("c:/a/./b/../c", r"C:\work", true).fs(), r"C:\a\c"); + assert_eq!(p("sub/f.txt", r"C:\work", true).fs(), r"C:\work\sub\f.txt"); + assert_eq!(p("~/notes.txt", r"C:\work", true).fs(), r"C:\Users\me\notes.txt"); + assert_eq!(p(r"\\host\share\x.txt", r"C:\work", true).fs(), r"\\host\share\x.txt"); + assert_eq!(p(r"\\?\C:\x.txt", r"C:\work", true).root, Root::Device(r"\\?\C:\x.txt".into())); + assert_eq!(p("caf%C3%A9.txt", r"C:\w", true).fs(), "C:\\w\\caf\u{e9}.txt"); + assert_eq!(p("..\\..\\..\\..", r"C:\w", true).fs(), r"C:\"); + assert_eq!(p("C:", r"D:\w", true).uri(), "/c"); + } + + #[test] + fn unix_paths_keep_backslashes_in_names() { + assert_eq!(p(r"a\b.txt", "/work", false).fs(), r"/work/a\b.txt"); + assert_eq!(p("/x/../y", "/work", false).fs(), "/y"); + assert_eq!(p("~", "/work", false).fs(), "/home/me"); + assert_eq!(p("/", "/work", false).uri(), "/"); + } + + #[test] + fn relative_uris_climb_to_the_common_folder() { + let base = p("/a/b", "/", false); + assert_eq!(p("/a/b/c d.txt", "/", false).relative_to(&base, false), "c%20d.txt"); + assert_eq!(p("/a/x.txt", "/", false).relative_to(&base, false), "../x.txt"); + assert_eq!(p("/a/b", "/", false).relative_to(&base, false), "."); + assert_eq!(p("D:/x", "C:/", true).relative_to(&p("C:/a", "C:/", true), true), "/d/x"); + } + + #[test] + fn uri_escapes_are_utf8_both_ways() { + assert_eq!(encode("caf\u{e9} 1"), "caf%C3%A9%201"); + assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()%2Fb"); + assert_eq!(decode("caf%C3%A9%20x"), "caf\u{e9} x"); + assert_eq!(decode("100%"), "100%"); + assert_eq!(decode("%E9"), "%E9", "a lone Latin-1 escape is not UTF-8"); + } + + #[test] + fn masks_match_stars_and_question_marks_without_case() { + let matches = |mask: &str, name: &str| Mask::new(mask).matches(name, &mut { usize::MAX }) == Some(true); + assert!(matches("*.JSX", "a.jsx")); + assert!(matches("a?c*", "abcdef")); + assert!(!matches("*.txt", "a.jsx")); + assert!(matches("*", "")); + assert!(matches("*a*b", "xaxxb")); + assert!(matches("a***b", "ab")); + assert!(!matches("?", "")); + } + + #[test] + fn masks_stop_when_their_steps_run_out() { + let mask = Mask::new(&format!("*{}b", "a".repeat(100))); + let name = "a".repeat(200); + let mut steps = 1000; + assert_eq!(mask.matches(&name, &mut steps), None); + assert_eq!(steps, 0); + let mut steps = 1000; + assert_eq!(Mask::new("*.jsx").matches("a.jsx", &mut steps), Some(true)); + assert!(steps > 990); + } + + #[test] + fn decoding_counts_characters_and_takes_four_line_ends() { + let mut f = Open::new(b"\xEF\xBB\xBFa\xC3\xA9\r\nb\n\rc\rd\ne".to_vec(), "").unwrap(); + assert_eq!((f.name.as_str(), f.pos), ("UTF-8", 3), "the byte order mark is passed over"); + assert_eq!(f.read(Some(2)).unwrap(), "a\u{e9}"); + assert_eq!(f.pos, 6, "two characters, three bytes"); + assert_eq!(f.read_line().unwrap(), "", "CRLF is one line end"); + assert_eq!(f.read_line().unwrap(), "b", "LFCR is one line end"); + assert_eq!(f.read_line().unwrap(), "c"); + assert_eq!(f.read_char().unwrap(), "d"); + assert_eq!(f.read_char().unwrap(), "\n"); + assert_eq!(f.read(None).unwrap(), "e"); + assert_eq!(f.read_char().unwrap(), ""); + let mut b = Open::new(vec![0xE8, 0x41], "BINARY").unwrap(); + assert_eq!(b.read(None).unwrap(), "\u{e8}A"); + let mut a = Open::new(vec![0x41, 0xE8], "ascii").unwrap(); + assert!(a.read(None).is_err()); + assert_eq!(a.pos, 0, "a failed read leaves the position"); + let mut c = Open::new(vec![0x80, 0xE9], "").unwrap(); + assert_eq!(c.name, "CP1252"); + assert_eq!(c.read(None).unwrap(), "\u{20ac}\u{e9}"); + let mut u = Open::new(vec![0xFF, 0xFE, 0x3D, 0xD8, 0x00, 0xDE, 0x41, 0x00], "").unwrap(); + assert_eq!(u.name, "UTF-16LE"); + assert_eq!(u.read(None).unwrap(), "\u{1f600}A"); + assert!(Open::new(b"x".to_vec(), "KLINGON").is_none()); + let mut l = Open::new(vec![b'a', b'\n', 0xE8, b'\r', 0xE8], "ASCII").unwrap(); + assert_eq!(l.read_line().unwrap(), "a", "a line end before a byte ASCII cannot read still ends the line"); + assert!(l.read_line().is_err()); + assert_eq!(l.pos, 2); + l.pos = 3; + assert_eq!(l.read_char().unwrap(), "\n"); + assert_eq!(l.pos, 4); + } +} diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index 70bfeac56a..6cf0c60bef 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -251,6 +251,11 @@ impl Files { self.include_path.join(";") } + /// The folders the run's own files are read from. + pub(crate) fn scope(&self) -> &ScriptScope { + &self.scope + } + /// The script's own text, `text`, with its preprocessor lines read. pub(crate) fn expand_script(&mut self, text: &str) -> Result { self.bytes = self.bytes.saturating_add(text.len()); diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 78002422d1..05f1387552 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -35,6 +35,8 @@ //! //! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the //! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`. +//! +//! `File` and `Folder` read files inside the script's own folder ([`files`]). pub mod console; pub mod dialogs; @@ -56,6 +58,7 @@ use crate::{ Clipboard, DocState, DrawMode, EngineError, Journal, NodeId, PaintDefaults, Result, Session, cmd, command_specs, guard, waits_for_tool_edit, }; +pub mod files; pub mod folder; /// The most bytes of source a run takes, as a file or as `source`. @@ -492,6 +495,8 @@ struct Host { dialogs: dialogs::Dialogs, /// The files the run reads: its includes and `$.evalFile`'s. files: RefCell, + /// What `File` and `Folder` have open and have read. + disk: RefCell, /// Where the lines of the script's expanded source came from. script_map: RefCell, /// The same for each source `$.evalFile` gave, in order. @@ -511,6 +516,10 @@ struct Counts { command_list_reads: Cell, vc_log: Cell, console_writes: Cell, + /// Calls of `File` and `Folder` that reach the host ([`files`]). + file_calls: Cell, + /// `execute` calls, which open nothing. + file_executes: Cell, } impl Counts { @@ -521,6 +530,8 @@ impl Counts { "commandListReads": self.command_list_reads.get(), "vcLog": self.vc_log.get(), "consoleWrites": self.console_writes.get(), + "fileCalls": self.file_calls.get(), + "fileExecutes": self.file_executes.get(), }) } } @@ -1082,7 +1093,7 @@ fn kind_id(kind: &RunErrorKind) -> &'static str { /// `message`, with what to do about a failure that comes from a script written for another program. fn explained(kind: &RunErrorKind, message: &str) -> String { - const HOST_OBJECTS: &[&str] = &["app", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"]; + const HOST_OBJECTS: &[&str] = &["app", "Window", "BridgeTalk", "activeDocument", "documents"]; let names_host_object = message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o))); if *kind == RunErrorKind::Thrown && names_host_object { @@ -1311,6 +1322,7 @@ impl Session { commands: RefCell::default(), run_nanos: Cell::new(0), dialogs: dialogs::Dialogs::new(self.script_frames, &realm, request.answers), + disk: RefCell::new(files::Disk::new(files.scope())), files: RefCell::new(files), script_map: RefCell::default(), eval_maps: RefCell::default(), @@ -1330,6 +1342,7 @@ impl Session { .define_host_object("vc", vc_object(&host, args)) .and_then(|()| realm.define_host_object("$", dollar_object(&host))) .and_then(|()| dialogs::install(&mut realm, &host)) + .and_then(|()| files::install(&mut realm, &host)) .and_then(|()| realm.start(&expanded.source)) .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2af42d2920..19cf098da8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -154,6 +154,7 @@ mod script_console; mod script_dialogs; mod script_folder; mod script_include; +mod script_io; mod script_run; mod scripts; mod select_ids_first; diff --git a/crates/engine/src/tests/script_console.rs b/crates/engine/src/tests/script_console.rs index ba57d96bdf..d70770a36f 100644 --- a/crates/engine/src/tests/script_console.rs +++ b/crates/engine/src/tests/script_console.rs @@ -137,7 +137,10 @@ fn the_result_and_the_report_agree_and_count_each_kind_of_host_call() { try { vc.run('no.such', {}); } catch (e) {} var d = vc.document; var c = vc.commands; vc.log({a: 1}); vc.log('two\\nlines'); $.writeln('w');"; let r = run(&mut s, source).unwrap(); assert_eq!(&r, s.script_report().unwrap()); - assert_eq!(r["hostCallCounts"], json!({"vcRun": 3, "documentReads": 1, "commandListReads": 1, "vcLog": 2, "consoleWrites": 1})); + assert_eq!( + r["hostCallCounts"], + json!({"vcRun": 3, "documentReads": 1, "commandListReads": 1, "vcLog": 2, "consoleWrites": 1, "fileCalls": 0, "fileExecutes": 0}) + ); assert_eq!(r["timingAvailable"], true); assert!(r["runTimeMs"].is_u64()); let slow = r["slowestCommands"].as_array().unwrap(); diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs new file mode 100644 index 0000000000..2dc1253c1b --- /dev/null +++ b/crates/engine/src/tests/script_io.rs @@ -0,0 +1,398 @@ +//! `File` and `Folder` in a script: their classes and names, reading inside the script's own folder, +//! the paths refused before anything is looked up, the read limits, and the parts that write, which +//! are not available. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::file_access::{AutomationRoots, confine, script_probes}; +use crate::*; + +const DENIED: &str = "Script file access denied"; + +/// A fresh folder for test `name`, with a `main` folder inside it for the script. +fn folder(name: &str) -> PathBuf { + let base = std::env::temp_dir().join(format!("vc-script-io-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("main")).unwrap(); + base +} + +fn write(path: &Path, bytes: &[u8]) { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(path, bytes).unwrap(); +} + +/// Runs `main/main.jsx` in `base`, with the text `text`. +fn run(base: &Path, text: &str) -> Value { + let main = base.join("main/main.jsx"); + write(&main, text.as_bytes()); + Session::new().execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap() +} + +/// The values the run of `text` logged. +fn logged(base: &Path, text: &str) -> Value { + run(base, text)["log"].clone() +} + +#[test] +fn file_constructor_identity_and_folder_path() { + let base = folder("identity"); + write(&base.join("main/data.txt"), b"x"); + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + let log = logged( + &base, + "var a = new File('data.txt'), b = new File('data.txt');\n\ + vc.log([a === b, a instanceof File, a instanceof Folder, File('sub') instanceof Folder, new File('sub') instanceof File,\n\ + Folder('data.txt') instanceof File, new Folder('data.txt') instanceof Folder, File('nope') instanceof File,\n\ + a.parent instanceof Folder, a.name, a.parent.fsName === Folder.current.fsName, String(a) === a.absoluteURI,\n\ + a.exists, new File('nope').exists, a.length, a.created instanceof Date, Folder('sub').exists, new File().exists]);", + ); + assert_eq!(log, json!([[false, true, false, true, true, true, true, true, true, "data.txt", true, true, true, false, 1, true, true, false]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn file_names_are_uri_encoded_and_current_relative() { + let base = folder("names"); + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + let log = logged( + &base, + "var f = new File('caf%C3%A9 x.txt');\n\ + var sep = File.fs === 'Windows' ? '\\\\' : '/';\n\ + vc.log([f.name, f.displayName, f.relativeURI, f.fsName === Folder.current.fsName + sep + 'caf\\u00e9 x.txt',\n\ + File.encode('\\u00e9 /'), File.decode('%C3%A9'), new File('sub/../sub/./y.txt').relativeURI,\n\ + new File('~/z').fsName === new Folder('~').fsName + sep + 'z', new File('a/b').path === new Folder('a').absoluteURI]);\n\ + Folder.current = new Folder('sub');\n\ + vc.log([new File('w').parent.name, new File('w').relativeURI, new File('../w').getRelativeURI(Folder.current.parent)]);\n\ + var l = new File('literal%2520name.txt'); Folder.current = new Folder('p%2541');\n\ + vc.log([l.displayName, l.name, l.absoluteURI.slice(-20), l.fsName === new File(l).fsName, Folder.current.displayName, new File('x').parent.displayName]);", + ); + assert_eq!( + log, + json!([ + ["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20%2F", "\u{e9}", "sub/y.txt", true, true], + ["sub", "w", "w"], + ["literal%20name.txt", "literal%2520name.txt", "literal%2520name.txt", true, "p%41", "p%41"] + ]) + ); + if cfg!(windows) { + let log = logged(&base, "vc.log([new File('/c/a b/x.txt').fsName, new File('C:/a/x.txt').absoluteURI, new File('d:\\\\q').fsName]);"); + assert_eq!(log, json!([["C:\\a b\\x.txt", "/c/a/x.txt", "D:\\q"]])); + } + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_without_scope_cannot_probe_home() { + let base = folder("home"); + let source = "var f = File('~/.ssh/id_test'); var g = new Folder('~');\n\ + vc.log([f instanceof File, f.exists, f.open('r'), f.error, g.getFiles(), g.error, f.modified, f.length]);"; + let denied = json!([[true, false, false, "Script file access denied", null, "Script file access denied", null, 0]]); + let before = script_probes(); + let r = Session::new().execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!(r["log"], denied, "a script given as text reads nothing"); + assert_eq!(script_probes(), before, "nothing was looked up"); + // A script run from a file is held to its folder. + let before = script_probes(); + assert_eq!(logged(&base, source), denied); + assert_eq!(script_probes(), before, "the home folder lies outside the script's folder by its names alone"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_unc_rejected_before_lookup() { + let base = folder("unc"); + let before = script_probes(); + let log = logged( + &base, + "var out = [];\n\ + var paths = ['\\\\\\\\host\\\\s\\\\x.txt', '//host/s/x.txt', '\\\\\\\\?\\\\C:\\\\x.txt', '\\\\\\\\.\\\\pipe\\\\x'];\n\ + for (var i = 0; i < paths.length; i++) { var f = new File(paths[i]); out.push(f.exists, f.open('r'), new Folder(paths[i]).getFiles()); }\n\ + vc.log(out);", + ); + assert_eq!(log, json!([[false, false, null, false, false, null, false, false, null, false, false, null]])); + assert_eq!(script_probes(), before, "no network or device path was looked up"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_link_escape_refused() { + let base = folder("links"); + write(&base.join("outside/secret.txt"), b"secret"); + write(&base.join("main/own.txt"), b"own"); + std::fs::create_dir_all(base.join("main/inner")).unwrap(); + // A folder link: a symbolic link, or on Windows a junction, which needs no privilege. + let link_folder = |target: &Path, link: &Path| { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output().unwrap(); + assert!(made.status.success(), "mklink /J: {}", String::from_utf8_lossy(&made.stderr)); + } + }; + link_folder(&base.join("outside"), &base.join("main").join("away")); + link_folder(&base.join("main").join("inner"), &base.join("main").join("near")); + let log = logged( + &base, + "var away = new Folder('away'); var s = new File('away/secret.txt');\n\ + vc.log([away.exists, away.getFiles(), away.error, away.resolve(), s.exists, s.open('r'), s.error,\n\ + Folder.current.getFiles().map(function (f) { return f.name; }), new Folder('near').alias, new Folder('near').resolve().name]);", + ); + assert_eq!(log, json!([[false, null, DENIED, null, false, false, DENIED, ["inner", "main.jsx", "near", "own.txt"], true, "inner"]])); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| std::os::windows::fs::symlink_file(target, link).is_ok(); + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make a link to a file. + if !made(&base.join("outside/secret.txt"), &base.join("main/leak.txt")) { + eprintln!("skipped: links to files can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(made(&base.join("main/own.txt"), &base.join("main/alias.txt"))); + let log = logged( + &base, + "var leak = new File('leak.txt');\n\ + vc.log([leak.exists, leak.open('r'), leak.error, leak.resolve(), new File('away/secret.txt').exists, new Folder('away').getFiles(),\n\ + Folder.current.getFiles().map(function (f) { return f.name; }), new File('alias.txt').alias, new File('alias.txt').resolve().name]);", + ); + assert_eq!(log, json!([[false, false, DENIED, null, false, null, ["alias.txt", "inner", "main.jsx", "near", "own.txt"], true, "own.txt"]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_oversize_refused_before_read() { + let base = folder("oversize"); + let big = std::fs::File::create(base.join("main/big.bin")).unwrap(); + big.set_len(9 << 20).unwrap(); + drop(big); + write(&base.join("main/small.txt"), b"ok"); + let r = + run(&base, "var f = new File('big.bin'); var s = new File('small.txt'); vc.log([f.length, f.open('r'), f.error, s.open('r'), s.read()]);"); + assert_eq!(r["log"], json!([[9437184, false, "Script file exceeds 8 MiB", true, "ok"]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_files_read_at_most_32_mib_in_a_run() { + let base = folder("total"); + write(&base.join("main/seven.bin"), &vec![b'a'; 7 << 20]); + let mut s = Session::new(); + let main = base.join("main/main.jsx"); + write(&main, b"var f = new File('seven.bin'); for (var i = 0; i < 5; i++) { f.open('r'); vc.log(i); }"); + let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})); + assert!(e.is_err(), "the fifth open passes 32 MiB"); + let report = s.script_report().unwrap(); + assert_eq!(report["log"], json!([0, 1, 2, 3])); + assert!(report["message"].as_str().unwrap().contains("32 MiB"), "{report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_files_have_at_most_256_open() { + let base = folder("open"); + write(&base.join("main/empty.txt"), b""); + let log = logged( + &base, + "var all = [], fails = 0, error = '';\n\ + for (var i = 0; i < 300; i++) { var f = new File('empty.txt'); if (f.open('r')) all.push(f); else { fails++; error = f.error; } }\n\ + var closed = all[0].close(); var again = new File('empty.txt');\n\ + vc.log([all.length, fails, error, closed, again.open('r'), all[1].open('r'), new File('empty.txt').open('r')]);", + ); + assert_eq!(log, json!([[256, 44, "Script has 256 files open", true, true, true, false]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_folder_getfiles_stays_inside_scope() { + let base = folder("list"); + for name in ["a.jsx", "b.txt", "C.JSX"] { + write(&base.join("main").join(name), b""); + } + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + write(&base.join("outside.jsx"), b""); + let log = logged( + &base, + "var d = Folder.current; var names = function (l) { var o = []; for (var i = 0; i < l.length; i++) o.push(l[i].name); return o; };\n\ + vc.log(names(d.getFiles('*.jsx')));\n\ + vc.log(names(d.getFiles(function (f) { return f instanceof Folder; })));\n\ + var up = d.parent; vc.log([names(d.getFiles('?.txt')), d.getFiles().length, up.getFiles(), up.error]);\n\ + try { d.getFiles(function () { throw new Error('stop'); }); } catch (e) { vc.log(e.message); }\n\ + vc.log([new Folder('nope').getFiles(), d.getFiles('*.jsx')[0] instanceof File]);", + ); + assert_eq!(log, json!([["C.JSX", "a.jsx", "main.jsx"], ["sub"], [["b.txt"], 5, null, DENIED], "stop", [null, true]])); + let many = base.join("main/many"); + for i in 0..=crate::script::files::MAX_ENTRIES { + write(&many.join(format!("{i:04}.txt")), b""); + } + let log = logged(&base, "var m = new Folder('many'); vc.log([m.getFiles(), m.error, m.getFiles('0001.*').length]);"); + assert_eq!(log, json!([[null, "Script folder has more than 2,048 entries", 1]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_bom_and_line_read() { + let base = folder("lines"); + write(&base.join("main/lines.txt"), b"\xEF\xBB\xBFone\r\ntwo\n\rthree\rfour\nfin"); + write(&base.join("main/e.txt"), "\u{e9}t\u{e9}".as_bytes()); + let log = logged( + &base, + "var f = new File('lines.txt'); var out = [f.eof, f.open('r'), f.encoding, f.tell()];\n\ + var lines = []; while (!f.eof) lines.push(f.readln()); out.push(lines, f.tell());\n\ + out.push(f.seek(3), f.read(3), f.seek(-3, 2), f.read(), f.seek(1, 1), f.seek(100), f.seek(-1), f.tell());\n\ + f.seek(6); out.push(f.readch(), f.readch(), f.tell(), f.close(), f.read(), f.error, f.eof);\n\ + var e = new File('e.txt'); e.open(); out.push(e.read(1), e.tell(), e.read(5), e.tell());\n\ + vc.log(out);", + ); + assert_eq!( + log, + json!([[ + true, + true, + "UTF-8", + 3, + ["one", "two", "three", "four", "fin"], + 27, + true, + "one", + true, + "fin", + false, + false, + false, + 27, + "\n", + "t", + 9, + true, + "", + "Script file is not open", + true, + "\u{e9}", + 2, + "t\u{e9}", + 5 + ]]) + ); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_encoding_binary_ascii() { + let base = folder("encodings"); + write(&base.join("main/bytes.bin"), &[0xE8, 0x41]); + write(&base.join("main/euro.txt"), &[0x80]); + let log = logged( + &base, + "var b = new File('bytes.bin'); b.encoding = 'BINARY'; b.open('r'); var t = b.read();\n\ + var a = new File('bytes.bin'); a.encoding = 'ASCII'; a.open('r');\n\ + var out = [t.charCodeAt(0), t.charAt(1), b.encoding, a.read(), a.error, a.tell()];\n\ + a.encoding = 'binary'; out.push(a.encoding, a.read().length);\n\ + var c = new File('euro.txt'); c.open('r'); out.push(c.encoding, c.read().charCodeAt(0));\n\ + var k = new File('bytes.bin'); k.encoding = 'KLINGON'; out.push(k.open('r'), k.error);\n\ + out.push(File.isEncodingAvailable('utf-8'), Folder.isEncodingAvailable('BINARY'), File.isEncodingAvailable('KLINGON'));\n\ + vc.log(out);\n\ + var m = new File('bytes.bin'); m.encoding = 'BINARY'; m.open('r'); m.encoding = 'KLINGON'; vc.log([m.error, m.encoding, m.read().length]);\n\ + var r = new File('bytes.bin'); r.open('r'); r.encoding = 'ASCII'; r.close(); vc.log([r.encoding, r.open('r'), r.read(), r.error]);", + ); + let undecodable = "Script file cannot be decoded"; + assert_eq!(log[0], json!([232, "A", "BINARY", "", undecodable, 0, "BINARY", 2, "CP1252", 0x20AC, false, undecodable, true, true, false])); + assert_eq!(log[1], json!([undecodable, "BINARY", 2]), "a name that is not an encoding leaves the open file's encoding"); + assert_eq!(log[2], json!(["ASCII", true, "", undecodable]), "an encoding set while open is kept after close"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_writes_dialogs_and_execute_are_not_available() { + let base = folder("writes"); + write(&base.join("main/keep.txt"), b"keep"); + let r = run( + &base, + "var f = new File('keep.txt'); var n = new File('new.txt');\n\ + var out = [n.open('w'), n.error, f.open('a'), f.open('e'), f.open('x'), f.error, f.write('x'), f.remove(), f.copy('c.txt'), f.rename('r.txt')];\n\ + out.push(new Folder('made').create(), f.openDlg(), f.error, File.openDialog(), Folder.selectDialog(), f.execute(), f.error);\n\ + f.hidden = true; out.push(f.error, f.hidden); f.error = ''; out.push(f.error);\n\ + vc.log(out);", + ); + assert_eq!( + r["log"], + json!([[ + false, + "Script file writing is not available", + false, + false, + false, + "Script file open mode must be r, w, e or a", + false, + false, + false, + false, + false, + null, + "Script file dialogs are not available", + null, + null, + false, + "Script files cannot be opened in another application", + "Script file writing is not available", + false, + "" + ]]) + ); + assert_eq!(r["hostCallCounts"]["fileExecutes"], 1); + assert!(r["hostCallCounts"]["fileCalls"].as_u64().unwrap() > 0); + assert_eq!(std::fs::read(base.join("main/keep.txt")).unwrap(), b"keep"); + assert!(!base.join("main/new.txt").exists() && !base.join("main/made").exists() && !base.join("main/c.txt").exists()); + let _ = std::fs::remove_dir_all(base); +} + +/// A run with automation roots in force reads inside its folder and the read roots, and a run given +/// as text reads nothing even where the roots allow it. +#[test] +fn script_files_read_inside_the_automation_read_roots() { + let base = folder("roots"); + write(&base.join("main/data.txt"), b"data"); + let source = "var f = new File('data.txt'); vc.log([f.exists, f.open('r'), f.error, f.read()]);"; + let roots = AutomationRoots::new(&[&base.join("main")], &[]).unwrap(); + assert_eq!(confine(roots.as_ref(), || logged(&base, source)), json!([[true, true, "", "data"]])); + let text = format!("var f = new File({:?}); vc.log([f.exists, f.error]);", base.join("main/data.txt").to_string_lossy()); + let r = confine(roots.as_ref(), || Session::new().execute("script.run", &json!({"source": text})).unwrap()); + assert_eq!(r["log"], json!([[false, "Script file access denied"]])); + let _ = std::fs::remove_dir_all(base); +} + +/// A path longer than 32,767 characters is refused before it is split into names. +#[test] +fn script_file_path_length_is_bounded() { + let base = folder("longpath"); + let log = logged( + &base, + "var s = 'a/'; while (s.length < 40000) s += s;\n\ + var out = []; try { new File(s); } catch (e) { out.push(e.name, e.message); }\n\ + try { new Folder('x').getRelativeURI(s); } catch (e) { out.push(e.name); }\n\ + out.push(new File(s.slice(0, 30000)).exists); vc.log(out);", + ); + assert_eq!(log, json!([["RangeError", "Script file path exceeds 32,767 characters", "RangeError", false]])); + let _ = std::fs::remove_dir_all(base); +} + +/// An error `File` or `Folder` throws is reported at the script's line that called it. +#[test] +fn script_file_errors_are_at_the_calling_line() { + let base = folder("errors"); + let mut s = Session::new(); + let main = base.join("main/main.jsx"); + write(&main, b"\n\n\nnew File('x').read(-1);"); + assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err()); + let report = s.script_report().unwrap(); + assert!(report["message"].as_str().unwrap().contains("RangeError: File.read: the count must be a finite number"), "{report}"); + assert_eq!(report["line"].as_u64(), Some(4), "{report}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/js/src/file_classes.rs b/crates/js/src/file_classes.rs new file mode 100644 index 0000000000..cc22f1834d --- /dev/null +++ b/crates/js/src/file_classes.rs @@ -0,0 +1,864 @@ +//! `File` and `Folder`, the classes the help pages +//! and +//! describe, as built-ins +//! whose file system work a host does ([`crate::Realm::define_file_classes`]). +//! +//! The constructors build their objects with and without `new`: `new File(path)` is always a +//! `File` and `new Folder(path)` always a `Folder`, while `File(path)` gives a `Folder` when the +//! host's `stat` says the path is a folder, and `Folder(path)` a `File` when it is a file. Two +//! objects for one path are different objects. An object keeps its path in the host's platform +//! form, its `error` text, the id of the file it has open (or -1), the encoding set before `open` +//! and its line feed, in properties that are not enumerable; every name the getters give comes from +//! the host's `parse` of that path. `Folder.current` is kept in the realm and starts at the host's +//! `current`. +//! +//! A method whose host call fails sets `error` and returns `false`, `null` or an empty string. A +//! count or position that is not a finite number, a negative count and a seek mode other than 0, 1 +//! or 2 throw a `RangeError`; a method called on an object that is not a `File` or `Folder` throws +//! a `TypeError`. `getFiles` with a function calls it with each entry and keeps those it returns a +//! true value for; every entry takes fuel. + +use crate::HostItem; +use crate::builtins::{arg, constructor, data, method, native, put_at}; +use crate::heap::{CONFIGURABLE, Class, HIDDEN, NativeFn, NativeKind, OBJECT_BYTES, ObjId, Object, PROP_BYTES, Prop, key, string_bytes}; +use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; +use crate::value::Value; + +/// The objects of `File` and `Folder`, kept by the realm, and `Folder.current`. +#[derive(Clone, Debug)] +pub(crate) struct Classes { + file_proto: ObjId, + folder_proto: ObjId, + host: ObjId, + current: Value, +} + +impl Classes { + /// The objects a collection keeps. + pub(crate) fn objects(&self) -> [ObjId; 3] { + [self.file_proto, self.folder_proto, self.host] + } +} + +const PATH: &str = "__vcPath"; +const ERROR: &str = "__vcError"; +const HANDLE: &str = "__vcHandle"; +const ENCODING: &str = "__vcEncoding"; +const LINE_FEED: &str = "__vcLineFeed"; + +/// The locations `Folder` has as properties, each read from the host's `locations` when it is read. +const PLACES: [&str; 10] = ["appData", "appPackage", "commonFiles", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"]; + +fn classes(i: &Interp) -> JsResult { + i.files.clone().ok_or_else(|| Abrupt::Internal("File and Folder are not installed".into())) +} + +/// Calls the host function `name` with `args`. +fn host(i: &mut Interp, name: &str, args: &[Value]) -> JsResult { + let c = classes(i)?; + let f = i.get_value(&Value::Object(c.host), &key(name))?; + i.call(&f, &Value::Undefined, args) +} + +/// The host's message `name`. +fn message(i: &mut Interp, name: &str) -> JsResult { + let c = classes(i)?; + let text = i.get_value(&Value::Object(c.host), &key("text"))?; + i.get_value(&text, &key(name)) +} + +fn field(i: &mut Interp, v: &Value, name: &str) -> JsResult { + i.get_value(v, &key(name)) +} + +/// The value of the own data property `name` of `o`, `undefined` when it has none. +fn slot(i: &Interp, o: ObjId, name: &str) -> JsResult { + Ok(match i.get_own_property(o, &key(name))? { + Some(Prop::Data { value, .. }) => value, + _ => Value::Undefined, + }) +} + +fn set_slot(i: &mut Interp, o: ObjId, name: &str, value: Value) -> JsResult<()> { + if let Value::String(s) = &value { + i.rooted(std::slice::from_ref(&value), |i| i.reserve_heap(PROP_BYTES + string_bytes(s.len())))?; + } + i.define(o, key(name), Prop::Data { value, flags: HIDDEN }) +} + +/// The object `this` and its path, or a `TypeError` naming `what` when it is not a `File` or +/// `Folder`. +fn this_path(i: &mut Interp, this: &Value, what: &str) -> JsResult<(ObjId, Value)> { + if let Some(o) = this.as_object() + && let path @ Value::String(_) = slot(i, o, PATH)? + { + return Ok((o, path)); + } + Err(i.type_error(&format!("{what}: the object is not a File or a Folder"))) +} + +/// The path `x` names, and whether it is already in the platform's form: the path of a `File` or +/// `Folder`, or `String(x)`, which may hold URI escapes. +fn path_text(i: &mut Interp, x: &Value) -> JsResult<(Value, bool)> { + if let Some(o) = x.as_object() + && let path @ Value::String(_) = slot(i, o, PATH)? + { + return Ok((path, true)); + } + let s = i.to_str(x)?; + Ok((Value::String(s), false)) +} + +/// The host's `parse` of the path `x` names, from `Folder.current`. +fn parse(i: &mut Interp, x: &Value) -> JsResult { + let (text, platform) = path_text(i, x)?; + parse_text(i, text, platform) +} + +/// The host's `parse` of `text`, from `Folder.current`; its URI escapes are decoded unless it is +/// already a `platform` path. +fn parse_text(i: &mut Interp, text: Value, platform: bool) -> JsResult { + let current = classes(i)?.current; + host(i, "parse", &[text, current, Value::Bool(platform)]) +} + +fn parsed(i: &mut Interp, this: &Value, what: &str, name: &str) -> JsResult { + let (_, path) = this_path(i, this, what)?; + let p = parse_text(i, path, true)?; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, name)) +} + +/// A new object with prototype `proto` for the path `fs`, already in the platform's form. +fn instance(i: &mut Interp, proto: ObjId, fs: Value) -> JsResult { + let line_feed = host_value(i, "lineFeed")?; + i.rooted(&[fs.clone(), line_feed.clone()], |i| { + i.reserve_heap(OBJECT_BYTES + 5 * PROP_BYTES)?; + let o = i.heap.alloc_object(Object::new(Class::Ordinary, Some(proto))); + i.rooted(&[Value::Object(o)], |i| { + set_slot(i, o, PATH, fs.clone())?; + set_slot(i, o, ERROR, Value::str(""))?; + set_slot(i, o, HANDLE, Value::Number(-1.0))?; + set_slot(i, o, ENCODING, Value::str(""))?; + set_slot(i, o, LINE_FEED, line_feed.clone())?; + Ok(Value::Object(o)) + }) + }) +} + +fn host_value(i: &mut Interp, name: &str) -> JsResult { + let c = classes(i)?; + i.get_value(&Value::Object(c.host), &key(name)) +} + +/// The host's `stat` of the object's path, or `None` after setting `error` when it failed. +fn stat(i: &mut Interp, this: &Value, what: &str) -> JsResult> { + let (o, path) = this_path(i, this, what)?; + let r = host(i, "stat", &[path])?; + i.rooted(std::slice::from_ref(&r), |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(None); + } + Ok(Some(r.clone())) + }) +} + +/// The field `name` of the object's `stat`, when it exists, or `missing`. +fn stat_field(i: &mut Interp, this: &Value, what: &str, name: &str, missing: Value) -> JsResult { + let Some(r) = stat(i, this, what)? else { return Ok(missing) }; + i.rooted(std::slice::from_ref(&r), |i| { + if !field(i, &r, "exists")?.truthy() && name != "exists" { + return Ok(missing); + } + field(i, &r, name) + }) +} + +/// The result of a host read: its `text`, or `failed` after setting `error`. +fn text_result(i: &mut Interp, o: ObjId, r: Value, failed: Value) -> JsResult { + i.rooted(std::slice::from_ref(&r), |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(failed); + } + field(i, &r, "text") + }) +} + +/// The id of the object's open file, or `None` after setting `error` when it has none. +fn opened(i: &mut Interp, o: ObjId) -> JsResult> { + let h = slot(i, o, HANDLE)?; + if matches!(h, Value::Number(n) if n >= 0.0) { + return Ok(Some(h)); + } + let m = message(i, "notOpen")?; + set_slot(i, o, ERROR, m)?; + Ok(None) +} + +fn is_open(i: &Interp, o: ObjId) -> JsResult> { + let h = slot(i, o, HANDLE)?; + Ok(matches!(h, Value::Number(n) if n >= 0.0).then_some(h)) +} + +/// Closes the object's open file, if it has one. +fn close_open(i: &mut Interp, o: ObjId) -> JsResult<()> { + if let Some(h) = is_open(i, o)? { + host(i, "close", &[h])?; + } + set_slot(i, o, HANDLE, Value::Number(-1.0)) +} + +fn date(i: &mut Interp, ms: Value) -> JsResult { + let Value::Number(t) = ms else { return Ok(Value::Null) }; + i.reserve_heap(OBJECT_BYTES)?; + let proto = i.intrinsics.date_proto; + Ok(Value::Object(i.heap.alloc_object(Object::new(Class::Date(t), Some(proto))))) +} + +/// A count of characters: `None` for `undefined`, else a whole number 0 or more. +fn count(i: &mut Interp, v: &Value, what: &str) -> JsResult> { + if matches!(v, Value::Undefined) { + return Ok(None); + } + let n = i.to_number(v)?; + if !n.is_finite() || n < 0.0 { + return Err(i.throw(ErrorType::Range, &format!("{what}: the count must be a finite number, 0 or more"))); + } + Ok(Some(n.floor())) +} + +// ---- constructors ---- + +fn file_ctor(i: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + make(i, args, construct, true) +} + +fn folder_ctor(i: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + make(i, args, construct, false) +} + +/// `new File(path)`, `File(path)`, `new Folder(path)` or `Folder(path)`. +fn make(i: &mut Interp, args: &[Value], construct: bool, file: bool) -> JsResult { + let c = classes(i)?; + let (own, other) = if file { (c.file_proto, c.folder_proto) } else { (c.folder_proto, c.file_proto) }; + let (path, platform) = match arg(args, 0) { + Value::Undefined if file => (host(i, "tempName", &[])?, true), + Value::Undefined => (c.current.clone(), true), + v => (v, false), + }; + let fs = i.rooted(std::slice::from_ref(&path), |i| { + let p = if platform { parse_text(i, path.clone(), true)? } else { parse(i, &path)? }; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs")) + })?; + let proto = if construct { + own + } else { + let st = i.rooted(std::slice::from_ref(&fs), |i| host(i, "stat", std::slice::from_ref(&fs)))?; + let flip = i.rooted(&[fs.clone(), st.clone()], |i| { + let error = field(i, &st, "error")?; + let exists = field(i, &st, "exists")?.truthy(); + let dir = field(i, &st, "dir")?.truthy(); + Ok(matches!(error, Value::Undefined) && exists && dir == file) + })?; + if flip { other } else { own } + }; + instance(i, proto, fs) +} + +// ---- both classes ---- + +fn absolute_uri(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "absoluteURI", "uri") +} + +fn fs_name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(this_path(i, &this, "fsName")?.1) +} + +fn relative_uri(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (_, path) = this_path(i, &this, "relativeURI")?; + let current = classes(i)?.current; + host(i, "relative", &[path, current]) +} + +fn name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "name", "name") +} + +fn display_name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "displayName", "display") +} + +fn path(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "path", "path") +} + +fn parent(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let up = parsed(i, &this, "parent", "parent")?; + if matches!(up, Value::Null | Value::Undefined) { + return Ok(Value::Null); + } + let proto = classes(i)?.folder_proto; + instance(i, proto, up) +} + +fn exists(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "exists", "exists", Value::Bool(false))?.truthy())) +} + +fn created(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let ms = stat_field(i, &this, "created", "created", Value::Null)?; + date(i, ms) +} + +fn modified(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let ms = stat_field(i, &this, "modified", "modified", Value::Null)?; + date(i, ms) +} + +fn alias(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let Some(r) = stat(i, &this, "alias")? else { return Ok(Value::Bool(false)) }; + let v = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "alias"))?; + Ok(Value::Bool(v.truthy())) +} + +fn get_error(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "error")?; + slot(i, o, ERROR) +} + +fn set_error(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "error")?; + let v = match arg(args, 0) { + Value::Undefined | Value::Null => Value::str(""), + v => Value::String(i.to_str(&v)?), + }; + set_slot(i, o, ERROR, v)?; + Ok(Value::Undefined) +} + +fn get_relative_uri(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (_, path) = this_path(i, &this, "getRelativeURI")?; + let base = match arg(args, 0) { + Value::Undefined => classes(i)?.current, + b => { + let p = i.rooted(std::slice::from_ref(&path), |i| parse(i, &b))?; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))? + } + }; + host(i, "relative", &[path, base]) +} + +fn change_path(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "changePath")?; + close_open(i, o)?; + let p = parse(i, &arg(args, 0))?; + let fs = i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))?; + set_slot(i, o, PATH, fs)?; + Ok(Value::Bool(true)) +} + +fn resolve(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "resolve")?; + let r = host(i, "resolve", &[path])?; + let (error, fs, dir) = i.rooted(std::slice::from_ref(&r), |i| Ok((field(i, &r, "error")?, field(i, &r, "fs")?, field(i, &r, "dir")?)))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Null); + } + if !matches!(fs, Value::String(_)) { + return Ok(Value::Null); + } + let c = classes(i)?; + instance(i, if dir.truthy() { c.folder_proto } else { c.file_proto }, fs) +} + +fn execute(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "execute")?; + let r = host(i, "execute", &[path])?; + let error = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "error"))?; + set_slot(i, o, ERROR, error)?; + Ok(Value::Bool(false)) +} + +fn to_string(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "toString", "uri") +} + +/// A method that writes: it sets `error` and returns `false`. +fn no_writes(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "write")?; + let m = message(i, "noWrites")?; + set_slot(i, o, ERROR, m)?; + Ok(Value::Bool(false)) +} + +/// A setter that writes: it sets `error` and changes nothing. +fn no_writes_setter(i: &mut Interp, this: Value, args: &[Value], construct: bool) -> JsResult { + no_writes(i, this, args, construct)?; + Ok(Value::Undefined) +} + +/// A dialog method: it sets `error` and returns `null`. +fn no_picker(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "dialog")?; + let m = message(i, "noPicker")?; + set_slot(i, o, ERROR, m)?; + Ok(Value::Null) +} + +fn null_static(_: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Null) +} + +fn text_static(i: &mut Interp, name: &str, args: &[Value]) -> JsResult { + let s = i.to_str(&arg(args, 0))?; + host(i, name, &[Value::String(s)]) +} + +fn encode(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "encode", args) +} + +fn decode(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "decode", args) +} + +fn encoding_available(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "encodingAvailable", args) +} + +// ---- File ---- + +fn length(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let Some(r) = stat(i, &this, "length")? else { return Ok(Value::Number(0.0)) }; + i.rooted(std::slice::from_ref(&r), |i| { + let regular = field(i, &r, "exists")?.truthy() && field(i, &r, "file")?.truthy(); + if regular { field(i, &r, "length") } else { Ok(Value::Number(0.0)) } + }) +} + +fn hidden(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "hidden", "hidden", Value::Bool(false))?.truthy())) +} + +fn readonly(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "readonly", "readonly", Value::Bool(false))?.truthy())) +} + +fn four_question_marks(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + this_path(i, &this, "creator")?; + Ok(Value::str("????")) +} + +fn eof(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "eof")?; + match is_open(i, o)? { + Some(h) => host(i, "eof", &[h]), + None => Ok(Value::Bool(true)), + } +} + +fn get_line_feed(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "lineFeed")?; + slot(i, o, LINE_FEED) +} + +fn set_line_feed(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "lineFeed")?; + let v = i.to_str(&arg(args, 0))?; + set_slot(i, o, LINE_FEED, Value::String(v))?; + Ok(Value::Undefined) +} + +fn get_encoding(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "encoding")?; + if let Some(h) = is_open(i, o)? { + return host(i, "encoding", &[h]); + } + Ok(match slot(i, o, ENCODING)? { + Value::String(s) if s.len() != 0 => Value::String(s), + _ => Value::str("UTF-8"), + }) +} + +fn set_encoding(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "encoding")?; + let v = Value::String(i.to_str(&arg(args, 0))?); + match is_open(i, o)? { + Some(h) => { + let r = i.rooted(std::slice::from_ref(&v), |i| host(i, "setEncoding", &[h, v.clone()]))?; + let error = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "error"))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + } else { + set_slot(i, o, ENCODING, v)?; + } + } + None => set_slot(i, o, ENCODING, v)?, + } + Ok(Value::Undefined) +} + +fn open(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "open")?; + let mode = match arg(args, 0) { + Value::Undefined => "r".to_string(), + m => i.to_str(&m)?.to_rust_string().to_lowercase(), + }; + let refusal = match mode.as_str() { + "r" => None, + "w" | "e" | "a" => Some("noWrites"), + _ => Some("badMode"), + }; + if let Some(name) = refusal { + let m = message(i, name)?; + set_slot(i, o, ERROR, m)?; + return Ok(Value::Bool(false)); + } + close_open(i, o)?; + let encoding = slot(i, o, ENCODING)?; + let r = host(i, "open", &[path, encoding])?; + let (error, id) = i.rooted(std::slice::from_ref(&r), |i| Ok((field(i, &r, "error")?, field(i, &r, "id")?)))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Bool(false)); + } + set_slot(i, o, HANDLE, id)?; + Ok(Value::Bool(true)) +} + +fn close(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "close")?; + if opened(i, o)?.is_none() { + return Ok(Value::Bool(false)); + } + close_open(i, o)?; + Ok(Value::Bool(true)) +} + +fn read(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "read")?; + let n = count(i, &arg(args, 0), "File.read")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "read", &[h, n.map_or(Value::Number(-1.0), Value::Number)])?; + text_result(i, o, r, Value::str("")) +} + +fn read_line(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "readln")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "readln", &[h])?; + text_result(i, o, r, Value::str("")) +} + +fn read_char(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "readch")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "readch", &[h])?; + text_result(i, o, r, Value::str("")) +} + +fn seek(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "seek")?; + let pos = i.to_number(&arg(args, 0))?; + if !pos.is_finite() { + return Err(i.throw(ErrorType::Range, "File.seek: the position must be a finite number")); + } + let mode = match arg(args, 1) { + Value::Undefined => 0.0, + m => i.to_number(&m)?, + }; + if mode != 0.0 && mode != 1.0 && mode != 2.0 { + return Err(i.throw(ErrorType::Range, "File.seek: the mode must be 0, 1 or 2")); + } + let Some(h) = opened(i, o)? else { return Ok(Value::Bool(false)) }; + host(i, "seek", &[h, Value::Number(pos.floor()), Value::Number(mode)]) +} + +fn tell(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "tell")?; + let Some(h) = opened(i, o)? else { return Ok(Value::Number(-1.0)) }; + host(i, "tell", &[h]) +} + +// ---- Folder ---- + +fn get_files(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "getFiles")?; + let mask = arg(args, 0); + let call = i.is_callable(&mask); + let pattern = if call || matches!(mask, Value::Undefined) { Value::str("*") } else { Value::String(i.to_str(&mask)?) }; + let r = host(i, "list", &[path, pattern])?; + i.rooted(&[r.clone(), mask.clone()], |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Null); + } + let entries = field(i, &r, "entries")?; + let length = field(i, &entries, "length")?; + let n = i.to_number(&length)?; + i.reserve_heap(OBJECT_BYTES)?; + let proto = i.intrinsics.array_proto; + let out = i.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: true }, Some(proto))); + i.rooted(&[Value::Object(out)], |i| { + let c = classes(i)?; + let mut kept = 0.0; + let mut at = 0.0; + while at < n { + i.use_fuel()?; + let e = i.get_value(&entries, &crate::value::number_to_str(at).flat())?; + let (fs, dir) = i.rooted(std::slice::from_ref(&e), |i| Ok((field(i, &e, "fs")?, field(i, &e, "dir")?.truthy())))?; + let item = instance(i, if dir { c.folder_proto } else { c.file_proto }, fs)?; + let keep = if call { + i.rooted(std::slice::from_ref(&item), |i| i.call(&mask, &Value::Undefined, std::slice::from_ref(&item)))?.truthy() + } else { + true + }; + if keep { + put_at(i, out, kept, item)?; + kept += 1.0; + } + at += 1.0; + } + Ok(Value::Object(out)) + }) + }) +} + +fn get_current(i: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + let c = classes(i)?; + instance(i, c.folder_proto, c.current) +} + +fn set_current(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let p = parse(i, &arg(args, 0))?; + let fs = i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))?; + if let Some(c) = i.files.as_mut() { + c.current = fs; + } + Ok(Value::Undefined) +} + +/// `Folder.`: a new `Folder` for the host's location `place`, or `null`. +fn place(i: &mut Interp, place: &str) -> JsResult { + let all = host(i, "locations", &[])?; + let fs = i.rooted(std::slice::from_ref(&all), |i| field(i, &all, place))?; + if !matches!(fs, Value::String(_)) { + return Ok(Value::Null); + } + let proto = classes(i)?.folder_proto; + instance(i, proto, fs) +} + +macro_rules! places { + ($($f:ident => $name:literal),* $(,)?) => { + $(fn $f(i: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + place(i, $name) + })* + /// The getter of each of [`PLACES`], in its order. + const PLACE_GETTERS: [NativeFn; 10] = [$($f),*]; + }; +} + +places!( + app_data => "appData", + app_package => "appPackage", + common_files => "commonFiles", + desktop => "desktop", + my_documents => "myDocuments", + startup => "startup", + system => "system", + temp => "temp", + trash => "trash", + user_data => "userData", +); + +// ---- install ---- + +/// Defines the accessor `name` on `on`, not enumerable, as the help pages' objects have them. +fn accessor(i: &mut Interp, on: ObjId, name: &'static str, get: NativeFn, set: Option) -> JsResult<()> { + let g = native(i, name, get, 0, NativeKind::Plain, false)?; + i.rooted(&[Value::Object(g)], |i| { + let s = match set { + Some(f) => Some(native(i, name, f, 1, NativeKind::Plain, false)?), + None => None, + }; + i.define(on, key(name), Prop::Accessor { get: Some(g), set: s, flags: CONFIGURABLE }) + }) +} + +/// What `File` and `Folder` both have. +fn common(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + accessor(i, proto, "absoluteURI", absolute_uri, None)?; + accessor(i, proto, "fullName", absolute_uri, None)?; + accessor(i, proto, "fsName", fs_name, None)?; + accessor(i, proto, "relativeURI", relative_uri, None)?; + accessor(i, proto, "name", name, None)?; + accessor(i, proto, "displayName", display_name, None)?; + accessor(i, proto, "path", path, None)?; + accessor(i, proto, "parent", parent, None)?; + accessor(i, proto, "exists", exists, None)?; + accessor(i, proto, "created", created, None)?; + accessor(i, proto, "modified", modified, None)?; + accessor(i, proto, "alias", alias, None)?; + accessor(i, proto, "error", get_error, Some(set_error))?; + method(i, proto, "toString", to_string, 0)?; + method(i, proto, "getRelativeURI", get_relative_uri, 1)?; + method(i, proto, "changePath", change_path, 1)?; + method(i, proto, "resolve", resolve, 0)?; + method(i, proto, "execute", execute, 0)?; + method(i, proto, "remove", no_writes, 0)?; + method(i, proto, "rename", no_writes, 1)?; + method(i, ctor, "encode", encode, 1)?; + method(i, ctor, "decode", decode, 1)?; + method(i, ctor, "isEncodingAvailable", encoding_available, 1)?; + let system = host_value(i, "system")?; + data(i, ctor, "fs", system, HIDDEN) +} + +fn file_members(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + accessor(i, proto, "length", length, Some(no_writes_setter))?; + accessor(i, proto, "hidden", hidden, Some(no_writes_setter))?; + accessor(i, proto, "readonly", readonly, Some(no_writes_setter))?; + accessor(i, proto, "creator", four_question_marks, None)?; + accessor(i, proto, "type", four_question_marks, None)?; + accessor(i, proto, "eof", eof, None)?; + accessor(i, proto, "lineFeed", get_line_feed, Some(set_line_feed))?; + accessor(i, proto, "encoding", get_encoding, Some(set_encoding))?; + method(i, proto, "open", open, 1)?; + method(i, proto, "close", close, 0)?; + method(i, proto, "read", read, 1)?; + method(i, proto, "readln", read_line, 0)?; + method(i, proto, "readch", read_char, 0)?; + method(i, proto, "seek", seek, 2)?; + method(i, proto, "tell", tell, 0)?; + method(i, proto, "write", no_writes, 1)?; + method(i, proto, "writeln", no_writes, 1)?; + method(i, proto, "copy", no_writes, 1)?; + method(i, proto, "createAlias", no_writes, 1)?; + method(i, proto, "openDlg", no_picker, 0)?; + method(i, proto, "saveDlg", no_picker, 0)?; + method(i, ctor, "openDialog", null_static, 0)?; + method(i, ctor, "saveDialog", null_static, 0) +} + +fn folder_members(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + method(i, proto, "getFiles", get_files, 1)?; + method(i, proto, "create", no_writes, 0)?; + method(i, proto, "selectDlg", no_picker, 0)?; + method(i, ctor, "selectDialog", null_static, 0)?; + accessor(i, ctor, "current", get_current, Some(set_current))?; + for (place, get) in PLACES.into_iter().zip(PLACE_GETTERS) { + accessor(i, ctor, place, get, None)?; + } + Ok(()) +} + +/// Adds `File` and `Folder` to the global object, their work done by the host functions in +/// `items` ([`crate::Realm::define_file_classes`]). +pub(crate) fn install(i: &mut Interp, items: Vec<(String, HostItem)>) -> JsResult<()> { + let host_obj = i.host_object("File", items)?; + let object_proto = i.intrinsics.object_proto; + i.rooted(&[Value::Object(host_obj)], |i| { + i.reserve_heap(2 * OBJECT_BYTES)?; + let file_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto))); + let folder_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto))); + i.files = Some(Classes { file_proto, folder_proto, host: host_obj, current: Value::str("") }); + let start = host(i, "current", &[])?; + if let Some(c) = i.files.as_mut() { + c.current = start; + } + let file = native(i, "File", file_ctor, 1, NativeKind::Plain, true)?; + constructor(i, "File", file, file_proto)?; + let folder = native(i, "Folder", folder_ctor, 1, NativeKind::Plain, true)?; + constructor(i, "Folder", folder, folder_proto)?; + common(i, file, file_proto)?; + common(i, folder, folder_proto)?; + file_members(i, file, file_proto)?; + folder_members(i, folder, folder_proto) + }) +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::{Data, HostItem, JsValue, Realm}; + + /// A host whose paths are `/` and a name, where `/d` is a folder holding `a` and `b` and every + /// other path is missing. + fn realm() -> Realm { + let f = |g: fn(&[Data]) -> Data| HostItem::Function(Rc::new(move |a: Vec| Ok(g(&a)))); + let text = |a: &[Data], i: usize| match a.get(i) { + Some(Data::String(s)) => s.clone(), + _ => String::new(), + }; + let obj = |fields: Vec<(&str, Data)>| Data::Object(fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); + let parse = move |a: &[Data]| { + let t = text(a, 0); + let fs = if t.starts_with('/') { t } else { format!("/{t}") }; + let name = fs.trim_start_matches('/').to_string(); + obj(vec![ + ("fs", Data::String(fs.clone())), + ("uri", Data::String(fs)), + ("name", Data::String(name.clone())), + ("display", Data::String(name)), + ("path", Data::String("/".into())), + ("parent", Data::String("/".into())), + ]) + }; + let messages = ["notOpen", "noWrites", "noPicker", "badMode"].map(|k| (k.to_string(), HostItem::Value(Data::String(k.into())))); + let stat = move |a: Vec| -> Result { + let dir = text(&a, 0) == "/d"; + Ok(obj(vec![("exists", Data::Bool(dir)), ("dir", Data::Bool(dir))])) + }; + let list = move |_: Vec| -> Result { + let e = |n: &str| obj(vec![("fs", Data::String(format!("/d/{n}"))), ("dir", Data::Bool(false))]); + Ok(obj(vec![("entries", Data::Array(vec![e("a"), e("b")]))])) + }; + let mut r = Realm::new(); + let items: Vec<(String, HostItem)> = vec![ + ("text".into(), HostItem::Object(messages.to_vec())), + ("system".into(), HostItem::Value(Data::String("Unix".into()))), + ("lineFeed".into(), HostItem::Value(Data::String("Unix".into()))), + ("current".into(), f(|_| Data::String("/".into()))), + ("parse".into(), HostItem::Function(Rc::new(move |a: Vec| Ok(parse(&a))))), + ("stat".into(), HostItem::Function(Rc::new(stat))), + ("list".into(), HostItem::Function(Rc::new(list))), + ("tempName".into(), f(|_| Data::String("/tmp1".into()))), + ("locations".into(), f(|_| Data::Object(vec![]))), + ]; + r.define_file_classes(items).unwrap(); + r + } + + #[test] + fn objects_keep_their_classes_through_collections() { + let mut r = realm(); + r.run("var kept = new File('x'); var listed = new Folder('d').getFiles(); File = null; Folder = null;").unwrap(); + r.collect_garbage(); + r.run("var many = []; for (var i = 0; i < 2000; i++) many.push(kept.parent);").unwrap(); + r.collect_garbage(); + let v = r.run( + "[kept.name, kept.fsName, listed.length, listed[1].name, kept.parent.getFiles(function (f) { return f.name === 'd/a'; }).length].join()", + ); + assert_eq!(v.unwrap(), JsValue::String("x,/x,2,d/b,1".into())); + } + + #[test] + fn the_constructor_picks_the_class_only_without_new() { + let mut r = realm(); + let v = r.run( + "var F = File, D = Folder; [F('d') instanceof D, new F('d') instanceof F, D('x') instanceof D, \ + typeof Object.keys(new F('x')).length, Object.keys(new F('x')).length].join()", + ); + assert_eq!(v.unwrap(), JsValue::String("true,true,true,number,0".into())); + let e = r.run("File.prototype.read.call({})").unwrap_err(); + assert!(e.message.contains("is not a File or a Folder"), "{e:?}"); + } +} diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 2a12baf3c3..c029954775 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -462,7 +462,7 @@ impl Interp { self.define(global, key(name), Prop::Data { value: Value::Object(o), flags: 0 }) } - fn host_object(&mut self, path: &str, items: Vec<(String, HostItem)>) -> JsResult { + pub(crate) fn host_object(&mut self, path: &str, items: Vec<(String, HostItem)>) -> JsResult { let o = self.new_object(); let mark = self.temp_roots.len(); self.temp_roots.push(Value::Object(o)); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index cacd5a95b9..3e68007638 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -175,6 +175,8 @@ pub(crate) struct Interp { pub(crate) held_bytes: usize, /// Why installing the built-ins failed; a realm with this set runs no script. pub(crate) install_error: Option, + /// `File` and `Folder`, once a host has added them ([`crate::Realm::define_file_classes`]). + pub(crate) files: Option, /// The clock and time zone `Date` reads. pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. @@ -395,6 +397,7 @@ impl Interp { join_stack: Vec::new(), held_bytes: 0, install_error: None, + files: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), pause: Rc::default(), @@ -587,6 +590,7 @@ impl Interp { } roots.objects.push(self.global); roots.objects.extend(self.intrinsics.all()); + roots.objects.extend(self.files.iter().flat_map(crate::file_classes::Classes::objects)); roots.envs.push(self.global_env); self.heap.collect(&roots); // The collection counts the live total again, so bytes already released are not in it. diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 3936154eb7..9c17d09d1c 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -46,6 +46,7 @@ mod chars; mod compiler; mod date_builtins; mod error; +mod file_classes; mod heap; mod host; mod interp; diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 13ed7d2624..4b653c0aa1 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -398,6 +398,20 @@ impl Realm { } } + /// Adds the global constructors `File` and `Folder`, whose file system work the host functions + /// in `items` do: `parse`, `relative`, `tempName`, `stat`, `open`, `close`, `read`, `readln`, + /// `readch`, `seek`, `tell`, `eof`, `encoding`, `setEncoding`, `list`, `resolve`, `execute`, + /// `encode`, `decode`, `encodingAvailable`, `current` and `locations`, with the values `system` + /// and `lineFeed` and the messages `text.notOpen`, `text.noWrites`, `text.noPicker` and + /// `text.badMode`. Each object keeps its path, its `error` text, its open file's id, its + /// encoding and its line feed in properties that are not enumerable. + pub fn define_file_classes(&mut self, items: Vec<(String, crate::HostItem)>) -> Result<(), RunError> { + match crate::file_classes::install(&mut self.interp, items) { + Ok(()) => Ok(()), + Err(e) => self.outcome(Err(e)).map(drop), + } + } + /// Defines the global function `name`, whose arguments arrive converted as `args` says /// ([`crate::HostItem::Converted`]). Unlike a host object, a script may replace or delete it, as /// it may a built-in function. diff --git a/docs/mcp.md b/docs/mcp.md index 9592012540..47f53d8a4a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1661,6 +1661,8 @@ VectorCraft sets these limits itself: | A dialog's message, title and prompt preset | 16 KiB, 256 bytes and 16 KiB | | `answers` | 10,000 answers and 1 MiB as JSON | | `dialogs` in the report | 10,000 dialogs and 1 MiB as JSON | +| Files `File.open` reads | 8 MiB a file, 32 MiB in a run, 256 open at once | +| `Folder.getFiles` | 2,048 entries, 65,536 names looked at | | Growth before a person is asked | 100,000 objects and 512 MiB of new document data | In the app, a script past the growth limit stops at a question to continue or to stop and undo. Stop, Escape and @@ -1673,7 +1675,9 @@ another file than the script) and `fuelLeft`. - `runTimeMs` is the time the script ran, its commands included. Waits between frames and for a person's answer are left out. `timingAvailable` is false on the web, where `runTimeMs` is 0. -- `hostCallCounts` is `{vcRun, documentReads, commandListReads, vcLog, consoleWrites}`. +- `hostCallCounts` is `{vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}`. + `fileCalls` counts the calls `File` and `Folder` make to the app, and `fileExecutes` the `execute` calls, which open + nothing. - `slowestCommands` lists at most 5 commands as `{id, calls, totalMs, maxMs}`, the longest in all first, then by id. - `console` is `{lines, droppedLines, truncatedLines}`: the run's own output, `vc.log` lines and uncaught error, as text. @@ -1732,6 +1736,54 @@ empty object are the same. vectorcraft-cli run --cmd script.run --params '{"source": "if (confirm(\"Add a frame?\")) vc.log(prompt(\"Name\", \"Frame\"));", "answers": [{"kind": "confirm", "value": true}, {"kind": "prompt", "value": "Border"}]}' ``` +The help pages https://extendscript.docsforadobe.dev/file-system-access/file-object/ and +https://extendscript.docsforadobe.dev/file-system-access/folder-object/ describe the `File` and `Folder` objects. A +script reads files with them: + +- `new File(path)` and `new Folder(path)` name a path without looking it up. A path is a platform path or a URI + path: a relative path is taken from `Folder.current`, which starts at the script's folder, `~` is the home folder, + `%xx` escapes are read as UTF-8, and on Windows `/` and `\` both separate names and `/c/` names drive C. `File(path)` + without `new` gives a `Folder` for an existing folder, and `Folder(path)` gives a `File` for an existing file. Two + objects for the same path are different objects. +- `name`, `displayName`, `fsName`, `absoluteURI`, `fullName`, `relativeURI`, `path`, `parent` and `toString()` come + from the path. `exists`, `length`, `created`, `modified`, `hidden`, `readonly` and `alias` read the file system. + `File.encode`, `File.decode` and `File.isEncodingAvailable` (and the same on `Folder`), `File.fs`, and the folders + `Folder.temp`, `Folder.desktop`, `Folder.myDocuments`, `Folder.userData` and the others are there too. +- `open('r')` opens a file to read, and `read([chars])`, `readln()`, `readch()`, `seek(pos[, mode])`, `tell()`, `eof` + and `close()` read it. `read`, `readln` and `readch` count characters, and `seek` and `tell` count bytes. `readln` + and `readch` take CR, LF, CRLF and LF CR as one line end. `folder.getFiles([mask])` lists a folder, sorted by name; + the mask matches `*` and `?` without regard to case, and a function as the mask keeps the entries it returns true + for. +- A method that fails returns `false`, `null` or an empty string and sets `error`, such as `Script file access + denied`, `Script file does not exist` or `Script file is not open`. A count or a position that is not a finite + number, or a negative count, throws a `RangeError`. + +VectorCraft reads files by these rules of its own: + +- A script run from a file reads only in that file's folder and the folders below it, as its includes are read, and + a script given as `source` reads no files. When automation read roots are set, as for a run over MCP or the command + line, the files must also be inside them. A path outside, a Windows network path (`\\host\share`) and a device path + (`\\?\`, `\\.\`) are refused before anything is looked up: `exists` is `false`, the dates and `getFiles` give `null`, + and `error` is `Script file access denied`. A path that leads out through a link or a junction is refused too, and + `getFiles` leaves such an entry out. +- `open` reads the whole file. A file larger than 8 MiB is refused from its size before any of it is read, with + `Script file exceeds 8 MiB`, and a run that opens more than 32 MiB in all ends with an error. A run has at most 256 + files open at once; another `open` returns `false` with `Script has 256 files open` until one is closed. +- The encoding is the one the file's byte order mark gives (UTF-8, UTF-16LE or UTF-16BE), otherwise the `encoding` + the script set before `open`, otherwise UTF-8 when the bytes are valid UTF-8, otherwise CP1252. `BINARY` reads each + byte as one character. A byte the encoding cannot read, such as one above 127 in `ASCII`, fails the read with + `Script file cannot be decoded` and leaves the position where it was. +- `getFiles` gives at most 2,048 entries and looks at most 65,536 names; past that it gives `null` with `Script folder + has more than 2,048 entries`. A mask that takes more than 16,777,216 steps to match against the names of one + folder gives `null` with `Script folder mask takes too many steps to match`. +- A path longer than 32,767 characters throws a `RangeError` (`Script file path exceeds 32,767 characters`) when a + `File` or `Folder` is made from it. +- Writing is not available yet: `open('w')`, `open('e')`, `open('a')`, `write`, `writeln`, `copy`, `remove`, + `rename`, `create` and `createAlias` return `false` with `Script file writing is not available`, and setting + `length`, `hidden` or `readonly` sets `error` to that message and changes nothing. The file + and folder dialogs return `null`, and `execute` opens nothing and returns `false`. In the web build every lookup + fails with `Script file access is unavailable on the web`. + Window › Script Console shows the session's script lines, oldest first: each run's start, its `$.write` and `$.writeln` output, its `vc.log` entries, its uncaught error as `name:line:column: message` (or `name: file:line:column: message` when the error is in another file), its end, and a report of diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8e78caf8e6..5f1d867769 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -734,6 +734,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. From 13098fa9f403c25252112e656ad065da3f78632b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Wed, 11 Nov 2026 08:30:33 +0000 Subject: [PATCH 23/49] MCP: list UI_COMMANDS in command_list and close catalogue gaps (#6460) Headless command_list exposes the desktop UI catalogue with ui: true, docs clarify effect vs command ids, and prompts cover spelling, variables and path tools. --- crates/mcp/src/headless.rs | 35 ++- crates/mcp/src/lib.rs | 1 + crates/mcp/src/prompts.rs | 110 +++++++ crates/mcp/src/tests.rs | 71 +++++ crates/mcp/src/tools/definitions.rs | 4 +- crates/mcp/src/ui_catalog.rs | 455 ++++++++++++++++++++++++++++ docs/gaps.md | 2 + docs/mcp.md | 47 ++- docs/roadmap.md | 1 + docs/target-app-parity.md | 3 +- 10 files changed, 709 insertions(+), 20 deletions(-) create mode 100644 crates/mcp/src/ui_catalog.rs diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 32735d9a02..4d4a746dd5 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -129,10 +129,17 @@ impl Headless { fn exec(&mut self, id: &str, params: &Value) -> Result { // A key the command's description doesn't name is an error, not a default (#1442). match HOST_COMMANDS.iter().find(|c| c.0 == id) { - Some((_, _, doc)) => vectorcraft_engine::check_keys(id, doc, params), - None => self.session.check_params(id, params), + Some((_, _, doc)) => vectorcraft_engine::check_keys(id, doc, params).map_err(|e| e.to_string())?, + None if vectorcraft_engine::find_command(id).is_some() => { + self.session.check_params(id, params).map_err(|e| e.to_string())?; + } + None if crate::ui_catalog::UI_COMMANDS.iter().any(|(cid, ..)| *cid == id) => { + return Err(format!( + "`{id}` is a desktop UI command (`ui: true`); start `vectorcraft --control 7979` and use `vectorcraft-cli mcp --connect`" + )); + } + None => self.session.check_params(id, params).map_err(|e| e.to_string())?, } - .map_err(|e| e.to_string())?; match id { "file.open" => self.open(params), "file.save" => self.save(params), @@ -146,9 +153,31 @@ impl Headless { fn commands(&self) -> Value { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); + let mut seen: std::collections::HashSet = + v.iter().filter_map(|c| c.get("id").and_then(Value::as_str).map(str::to_owned)).collect(); for (id, label, params) in HOST_COMMANDS { + if !seen.insert((*id).to_owned()) { + continue; + } v.push(json!({"id": id, "label": label, "menu": [], "shortcut": null, "params": params, "enabled": true, "host": true})); } + // Desktop menu commands (`UI_COMMANDS`): listed for discovery with `ui: true`. Running one + // that only the desktop app implements still needs `--connect` (see `exec`). + for (id, label, shortcut, params) in crate::ui_catalog::UI_COMMANDS { + if !seen.insert((*id).to_owned()) { + continue; + } + let sc = if shortcut.is_empty() { Value::Null } else { json!(shortcut) }; + v.push(json!({ + "id": id, + "label": label, + "menu": [], + "shortcut": sc, + "params": params, + "enabled": true, + "ui": true, + })); + } Value::Array(v) } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 785746a38f..d2f1145361 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -22,6 +22,7 @@ mod prompts; mod resources; mod server; mod tools; +mod ui_catalog; pub use backend::{Backend, Remote}; pub use headless::Headless; diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 8ad74c6ba4..95363fde00 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -365,6 +365,116 @@ Method: Live effects are kept on export (geometry baked; shadows, glows and blur become SVG filters), and text stays text unless you pass outlineText.", }, + PromptDef { + name: "check-spelling", + title: "Check spelling", + description: "Run text.spelling.check on the selection or document, then apply suggestions or dictionary changes.", + arguments: &[ + PromptArg { + name: "scope", + description: "What to check", + required: false, + default: "the selection, or every story when nothing is selected", + values: Values::Fixed(&[ + "the selection, or every story when nothing is selected", + "the active story only", + "every story in the document", + ]), + }, + PromptArg { + name: "action", + description: "What to do with each unknown word", + required: false, + default: "list suggestions and ask before changing", + values: Values::Fixed(&[ + "list suggestions and ask before changing", + "apply the first suggestion for each word", + "add unknown words to the custom dictionary", + "ignore all for this document", + ]), + }, + ], + text: "\ +Check spelling in VectorCraft for {scope}, then {action}. + +Method: +1. inspect_document to see the selection and type objects. +2. run_command text.spelling.check (and text.spelling.suggest for each unknown word). Offsets are + byte ranges for text.editRange / text.spelling.changeWord. +3. For a fix, text.spelling.changeWord; for the custom dictionary, text.spelling.addWord / + removeWord / dictionary; for this document only, text.spelling.ignoreAll. +4. Prefer those commands over inventing replacements. screenshot if the layout may reflow. + +Desktop-only Auto Spell Check is edit.autoSpellCheck (ui: true); use --connect for the underlines.", + }, + PromptDef { + name: "variables-bind", + title: "Bind variables", + description: "Define text, visibility or image variables, bind them to art, and load or save a variable library.", + arguments: &[ + PromptArg { + name: "kind", + description: "Which variable kinds to set up", + required: false, + default: "text", + values: Values::Fixed(&["text", "visibility", "image", "graph", "mixed"]), + }, + PromptArg { + name: "source", + description: "Where the data comes from", + required: false, + default: "define variables on the selection by hand", + values: Values::Fixed(&[ + "define variables on the selection by hand", + "import a CSV or TSV data set", + "load a variable library XML", + ]), + }, + ], + text: "\ +Set up {kind} variables in VectorCraft from {source}. + +Method: +1. inspect_document and variable.list (or the Variables panel commands) to see what exists. +2. variable.define / variable.bind for text, visibility, image or graph bindings; one text + variable per type object. +3. For CSV/TSV, dataset.import; for a library file, variable.loadLibrary / variable.saveLibrary. +4. Switch data sets with dataset.apply and verify with inspect_document / screenshot. +5. Prefer run_command over inventing panel clicks. Undo covers each binding step.", + }, + PromptDef { + name: "path-tools", + title: "Path Tools add-on", + description: "Use addon.pathtools.* commands (and addon.path.* live effects) to edit path geometry.", + arguments: &[ + PromptArg { + name: "task", + description: "What to do to the selection", + required: true, + default: "", + values: Values::Fixed(&[ + "extend open ends", + "close paths", + "add anchors along curves", + "simplify anchors", + "split at anchors", + "expand dashes to geometry", + "create a parametric shape", + ]), + }, + ], + text: "\ +Perform this Path Tools task in VectorCraft: {task}. + +Method: +1. command_list with filter \"addon.pathtools\" for the command ids (extend, close, addPoints, + simplify, crossings, shape.create, …). Those are commands. +2. Live Path Effects keep the effect ids addon.path.* (dashes, addAnchors, extend, close, + smooth, retract, reverse, simplify, split). Apply them with apply_effect / effect.apply; + they are not command ids. +3. inspect_document the selection, run the command or effect, then screenshot. +4. Expand Appearance (effect.expandAppearance) only when the brief needs baked geometry.", + }, ]; /// The message body as a client should receive it. diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index dba3ed9def..16e15bed35 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -209,9 +209,80 @@ fn tool_schemas_are_valid() { "export", "undo", "redo", + "command_list", + "command_run", + "command_batch", + "doc_inspect", + "render_preview", + "ui_inspect", + "ui_screenshot", + "type_text", + "answer_dialog", + "add_text", + "apply_effect", + "pathfinder", + "transform", + "create_graph", + "text_wrap", ] { assert!(names.contains(want), "missing tool {want}"); } + assert_eq!(tools.len(), 33, "parity docs and #6460 expect 33 MCP tools"); +} + +/// Headless `command_list` must expose engine commands that a stale release CLI used to omit (#6460). +#[test] +fn headless_command_list_includes_post_022_engine_commands() { + let mut s = server(); + rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION})); + s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#); + let want = [ + "actionSet.read", + "engine.apiReference", + "script.run", + "script.stop", + "text.spelling.check", + "document.move", + "document.compare", + "edit.newDocumentFromState", + ]; + for (n, id) in want.iter().enumerate() { + let r = call(&mut s, 10 + n as u64, "command_list", json!({"filter": id, "brief": true})); + assert_eq!(r["isError"], false, "{id}: {r}"); + let list: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert!( + list.as_array().unwrap().iter().any(|c| c["id"] == *id), + "missing {id} in {list}" + ); + } +} + +/// Headless `command_list` lists desktop UI commands with `ui: true` (#6460). +#[test] +fn headless_command_list_includes_ui_commands() { + let mut s = server(); + rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION})); + s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#); + let want = [ + "addon.arrange.zoomSelection", + "file.newDialog", + "view.fitArtboard", + "view.rotateReset", + "app.quit", + "window.arrange.tile", + "align.horizontalLeft", + "ui.action.play", + ]; + for (n, id) in want.iter().enumerate() { + let r = call(&mut s, 20 + n as u64, "command_list", json!({"filter": id, "brief": true})); + assert_eq!(r["isError"], false, "{id}: {r}"); + let list: Value = serde_json::from_str(&text_of(&r)).unwrap(); + let entry = list.as_array().unwrap().iter().find(|c| c["id"] == *id).unwrap_or_else(|| panic!("missing {id} in {list}")); + assert_eq!(entry["ui"], true, "{entry}"); + } + let r = call(&mut s, 40, "command_run", json!({"id": "view.fitArtboard", "params": {}})); + assert_eq!(r["isError"], true, "{r}"); + assert!(text_of(&r).contains("--connect"), "{r}"); } // ---------- headless end-to-end ---------- diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 27d92394ff..2480f4ad69 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -98,7 +98,7 @@ pub fn tool_definitions() -> Vec { tool( "command_list", "List commands", - "Discover command ids, labels, parameter descriptions and enabled state. A command with a parameter schema also lists it as schema (JSON Schema 2020-12) and as inputSchema (the same schema with an object root, the form of a tool's inputSchema). Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.", + "Discover command ids, labels, parameter descriptions and enabled state. Lists engine and plug-in commands, and desktop UI menu commands (ui: true; running a UI-only id headless needs --connect). A command with a parameter schema also lists it as schema (JSON Schema 2020-12) and as inputSchema (the same schema with an object root, the form of a tool's inputSchema). Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}. Effect ids (effect.list) are not command ids.", obj( json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}), &[], @@ -156,7 +156,7 @@ pub fn tool_definitions() -> Vec { tool( "list_commands", "List commands", - "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state, and schema and inputSchema for a command with a parameter schema). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.", + "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state, and schema and inputSchema for a command with a parameter schema). Includes engine commands and desktop UI menu commands (ui: true; a UI-only id run headless needs --connect). Every editing action is a command; run any of them with run_command (or invoke_menu for a menu path). Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only. Effect ids (effect.list) are not command ids.", obj( json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}), &[], diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs new file mode 100644 index 0000000000..c877045c28 --- /dev/null +++ b/crates/mcp/src/ui_catalog.rs @@ -0,0 +1,455 @@ +//! Desktop UI command catalogue for headless MCP discovery (`ui: true`). +//! Generated from `crates/ui-egui/src/menus/ui_commands.rs`. Re-run the extractor when that table changes. + +/// `(id, label, shortcut, params doc)` — same shape as the desktop `UI_COMMANDS` table. +pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ + ("addon.pathtools.cornerTool", "Corner Tool", "", "{} choose the Corner Tool and show the Anchor Inspector panel with its radius and kind"), + ("addon.pathtools.sharedEdgeTool", "Shared Edge Merge Tool", "", "{} choose the Shared Edge Merge Tool and show the Anchor Inspector panel with its brush, coincidence and Keep Shared Points Together"), + ("addon.arrange.zoomSelection", "Zoom to Selection", "", "{} zoom and centre the active view so the selection's visual bounds fill the window, with a margin (as view.fitArtboard fits the artboard); off with nothing selected"), + ("app.language", "Interface Language", "", "{lang: auto|} the interface language, persisted as the `interfaceLanguage` preference (`auto` follows the system locale; codes: prefs.list › interfaceLanguage, e.g. en, ja, cs, es, zh-hant)"), + ("file.open", "Open…", "Cmd+O", "{path?, params?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads); `params` are the parameters of the importer plug-in that opens the file"), + ("file.openWith", "Open With", "", "{importer: \\\"plugin.\\\" (an importer plug-in's format id, see document.formats), path?} File › Open With: open the file with that importer (document.open's importer), also a file whose extension a built-in format reads; without a path pick one of the files with the extensions it lists (the web opens File › Open's picker, and the file opens as File › Open opens it) → with a path, document.open's result; a picked file is read off the UI thread on the desktop, {background: true, status}, and opens once the importer returns (file.cancelOpen stops it) | null while the picker is open"), + ("file.save", "Save", "Cmd+S", "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first"), + ("file.place", "Place…", "Cmd+Shift+P", "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)"), + ("file.openRecent1", "Open Recent File 1", "", "{}"), + ("file.openRecent2", "Open Recent File 2", "", "{}"), + ("file.openRecent3", "Open Recent File 3", "", "{}"), + ("file.openRecent4", "Open Recent File 4", "", "{}"), + ("file.openRecent5", "Open Recent File 5", "", "{}"), + ("file.openRecent6", "Open Recent File 6", "", "{}"), + ("file.openRecent7", "Open Recent File 7", "", "{}"), + ("file.openRecent8", "Open Recent File 8", "", "{}"), + ("file.openRecent9", "Open Recent File 9", "", "{}"), + ("file.openRecent10", "Open Recent File 10", "", "{}"), + ("view.goto1", "Saved View 1", "", "{} go to the 1. saved view"), + ("view.goto2", "Saved View 2", "", "{} go to the 2. saved view"), + ("view.goto3", "Saved View 3", "", "{} go to the 3. saved view"), + ("view.goto4", "Saved View 4", "", "{} go to the 4. saved view"), + ("view.goto5", "Saved View 5", "", "{} go to the 5. saved view"), + ("view.goto6", "Saved View 6", "", "{} go to the 6. saved view"), + ("view.goto7", "Saved View 7", "", "{} go to the 7. saved view"), + ("view.goto8", "Saved View 8", "", "{} go to the 8. saved view"), + ("view.goto9", "Saved View 9", "", "{} go to the 9. saved view"), + ("view.goto10", "Saved View 10", "", "{} go to the 10. saved view"), + ("view.goto11", "Saved View 11", "", "{} go to the 11. saved view"), + ("view.goto12", "Saved View 12", "", "{} go to the 12. saved view"), + ("view.goto13", "Saved View 13", "", "{} go to the 13. saved view"), + ("view.goto14", "Saved View 14", "", "{} go to the 14. saved view"), + ("view.goto15", "Saved View 15", "", "{} go to the 15. saved view"), + ("view.goto16", "Saved View 16", "", "{} go to the 16. saved view"), + ("view.goto17", "Saved View 17", "", "{} go to the 17. saved view"), + ("view.goto18", "Saved View 18", "", "{} go to the 18. saved view"), + ("view.goto19", "Saved View 19", "", "{} go to the 19. saved view"), + ("view.goto20", "Saved View 20", "", "{} go to the 20. saved view"), + ("view.goto21", "Saved View 21", "", "{} go to the 21. saved view"), + ("view.goto22", "Saved View 22", "", "{} go to the 22. saved view"), + ("view.goto23", "Saved View 23", "", "{} go to the 23. saved view"), + ("view.goto24", "Saved View 24", "", "{} go to the 24. saved view"), + ("view.goto25", "Saved View 25", "", "{} go to the 25. saved view"), + ("select.recall1", "Saved Selection 1", "", "{} select the 1. saved selection"), + ("select.recall2", "Saved Selection 2", "", "{} select the 2. saved selection"), + ("select.recall3", "Saved Selection 3", "", "{} select the 3. saved selection"), + ("select.recall4", "Saved Selection 4", "", "{} select the 4. saved selection"), + ("select.recall5", "Saved Selection 5", "", "{} select the 5. saved selection"), + ("select.recall6", "Saved Selection 6", "", "{} select the 6. saved selection"), + ("select.recall7", "Saved Selection 7", "", "{} select the 7. saved selection"), + ("select.recall8", "Saved Selection 8", "", "{} select the 8. saved selection"), + ("select.recall9", "Saved Selection 9", "", "{} select the 9. saved selection"), + ("select.recall10", "Saved Selection 10", "", "{} select the 10. saved selection"), + ("select.recall11", "Saved Selection 11", "", "{} select the 11. saved selection"), + ("select.recall12", "Saved Selection 12", "", "{} select the 12. saved selection"), + ("select.recall13", "Saved Selection 13", "", "{} select the 13. saved selection"), + ("select.recall14", "Saved Selection 14", "", "{} select the 14. saved selection"), + ("select.recall15", "Saved Selection 15", "", "{} select the 15. saved selection"), + ("select.recall16", "Saved Selection 16", "", "{} select the 16. saved selection"), + ("select.recall17", "Saved Selection 17", "", "{} select the 17. saved selection"), + ("select.recall18", "Saved Selection 18", "", "{} select the 18. saved selection"), + ("select.recall19", "Saved Selection 19", "", "{} select the 19. saved selection"), + ("select.recall20", "Saved Selection 20", "", "{} select the 20. saved selection"), + ("select.recall21", "Saved Selection 21", "", "{} select the 21. saved selection"), + ("select.recall22", "Saved Selection 22", "", "{} select the 22. saved selection"), + ("select.recall23", "Saved Selection 23", "", "{} select the 23. saved selection"), + ("select.recall24", "Saved Selection 24", "", "{} select the 24. saved selection"), + ("select.recall25", "Saved Selection 25", "", "{} select the 25. saved selection"), + ("type.recentFont1", "Recent Font 1", "", "{} apply the 1. most recently used font"), + ("type.recentFont2", "Recent Font 2", "", "{} apply the 2. most recently used font"), + ("type.recentFont3", "Recent Font 3", "", "{} apply the 3. most recently used font"), + ("type.recentFont4", "Recent Font 4", "", "{} apply the 4. most recently used font"), + ("type.recentFont5", "Recent Font 5", "", "{} apply the 5. most recently used font"), + ("type.recentFont6", "Recent Font 6", "", "{} apply the 6. most recently used font"), + ("type.recentFont7", "Recent Font 7", "", "{} apply the 7. most recently used font"), + ("type.recentFont8", "Recent Font 8", "", "{} apply the 8. most recently used font"), + ("type.recentFont9", "Recent Font 9", "", "{} apply the 9. most recently used font"), + ("type.recentFont10", "Recent Font 10", "", "{} apply the 10. most recently used font"), + ("type.recentFont11", "Recent Font 11", "", "{} apply the 11. most recently used font"), + ("type.recentFont12", "Recent Font 12", "", "{} apply the 12. most recently used font"), + ("type.recentFont13", "Recent Font 13", "", "{} apply the 13. most recently used font"), + ("type.recentFont14", "Recent Font 14", "", "{} apply the 14. most recently used font"), + ("type.recentFont15", "Recent Font 15", "", "{} apply the 15. most recently used font"), + ("file.clearRecent", "Clear Recent Files", "", "{}"), + ("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"), + ("ui.missingFontsDialog", "Missing Fonts Dialog", "", "{folder?} open Missing Fonts (dialog `missingFonts`) for the fonts the active document uses that aren't available (text.missingFonts); with folder, it looks for their files there at once (text.findFontFiles). An error when no font is missing. Opening a document whose fonts are missing shows it after the missing linked file questions (dialog `missingLinks`). Disabled on the web"), + ("ui.findFontsInFolder", "Find Fonts in Folder…", "", "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)"), + ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), + ("file.export.svg", "Export As SVG…", "", "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)"), + ("file.export.png", "Export As PNG…", "", "{path?, …document.export options}; no path: pick the file, then PNG Options"), + ("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager; a folder on the desktop with Background Export on: the files are written in the background → {background: true, status})"), + ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), + ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), + ("file.newWithoutDialog", "New Without Dialog", "Cmd+Alt+N", "{} makes a document with the settings the New Document dialog opens on (the first Recent entry, else the Print category's Letter), named after the next untitled document, without opening the dialog → {index, previewMode}"), + ("app.home", "Home", "", "{} shows the Home screen (new file presets, Open) over the open documents; choosing a document tab returns to it"), + ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), + ("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"), + ("edit.autoSpellCheck", "Auto Spell Check", "", "{} toggle a red underline under the words of the document's type its dictionaries don't know (text.spelling.check lists them), kept up to date as the type changes; type with effects isn't underlined → whether Auto Spell Check is on"), + ("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:, shortcut: \\\"Cmd+Shift+K\\\" | \\\"\\\" (none) | null (default), force?}"), + ("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"), + ("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"), + ("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"), + ("shortcuts.preset", "Keyboard Shortcut Set", "", "{name: \\\"VectorCraft Defaults\\\" | \\\"Classic Defaults\\\"} (names of earlier versions are accepted)"), + ("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"), + ("shortcuts.exportText", "Export Keyboard Shortcuts as Text…", "", "{path?} save the current set's tools and menu commands with their shortcuts as a UTF-8 text file (Keyboard Shortcuts › Export Text…): the set's name, then a Tools and a Menu Commands section in the dialog's order, one `nameshortcut` line per row, empty after the tab when it has none; without `path`, pick a file"), + ("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"), + ("ui.shortcutImportDialog", "Import Shortcut Set…", "", "{path? | dataBase64?, name?} open Import Shortcut Set for a shortcut set another app saved as text (.kys; without a file, pick one): each entry matched to a tool, command or panel, and its key and modifier codes decoded (dialog `shortcutImport`: name, start: defaults|current, remember, rows: [{name, section, line, key, modifiers, target: entry id | \\\"\\\" (no equivalent) | null (not chosen), from, choices, on}], keys and mods: {code: {text, from, votes, names}}); Apply assigns the ready rows over Edit › Keyboard Shortcuts' working set, remembers the matches (importMatches.set) and shows Keyboard Shortcuts, whose OK keeps them. File › Open of a .kys opens it too"), + ("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"), + ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), + ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), + ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), + ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), + ("view.snapToGlyph", "Snap to Glyph", "", "{} toggle: drawing snaps to live-text glyph bounds, and on a single line of type to its baseline, x-height, character widths and glyph anchor points"), + ("view.preciseCursors", "Use Precise Cursors", "", "{} toggle General › Use Precise Cursors: the drawing tools' pointers become a crosshair → true when on"), + ("imageTrace.preview", "Image Trace Preview", "", "{on?: bool} turn the Image Trace panel's Preview on (true), off (false) or toggle it (omitted), as its checkbox does: on, setting changes trace the selected Image Trace object at once; off, they wait for Trace; returns the new state"), + ("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"), + ("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"), + ("view.livePaintGaps", "Show Live Paint Gaps", "", "{} toggle drawing the gaps of the selected Live Paint groups (those livePaint.info lists) in each group's Gap Preview Color while edges show → true when on"), + ("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"), + ("type.alignLeft", "Align left", "Cmd+Shift+L", "{} align the selected type's paragraphs (or those the Type tool's selection touches) left"), + ("type.alignCenter", "Align center", "Cmd+Shift+C", "{} align the selected type's paragraphs (or those the Type tool's selection touches) centred"), + ("type.alignRight", "Align right", "Cmd+Shift+R", "{} align the selected type's paragraphs (or those the Type tool's selection touches) right"), + ("type.justifyLeft", "Justify with last line aligned left", "Cmd+Shift+J", "{} justify the selected type's paragraphs, last line aligned left"), + ("type.justifyAll", "Justify all lines", "Cmd+Shift+F", "{} justify every line of the selected type's paragraphs"), + ("type.applyBullets", "Apply Bullets", "", "{bullet?: \\\"dot\\\"|\\\"dash\\\"|\\\"circle\\\"|\\\"square\\\"|\\\"none\\\"} Type › Bullets and Numbering › Apply Bullets: the selected type's paragraphs (or those the Type tool's selection touches) become a bulleted list with that bullet (text.setFormat {bullet}); without it, a list keeps its bullet and other paragraphs take the dot (text.setFormat {list: \\\"bullet\\\"}); \\\"none\\\" turns the list off"), + ("type.applyNumbers", "Apply Numbers", "", "{number?: \\\"arabic\\\"|\\\"upperLetters\\\"|\\\"lowerLetters\\\"|\\\"upperRoman\\\"|\\\"lowerRoman\\\"|\\\"twoDigits\\\"|\\\"threeDigits\\\"|\\\"none\\\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \\\"number\\\"}); \\\"none\\\" turns the list off"), + ("ui.bulletsAndNumbering", "Bullets and Numbering…", "", "{tab?: \\\"bullets\\\"|\\\"numbers\\\"} open Bullets and Numbering (More Options in the Paragraph panel's Bullets and Numbered List presets) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, on that tab (without it, Numbers when the paragraphs are a numbered list, else Bullets) (dialog `bulletsAndNumbering`: tab (bullets|numbers), list (none|bullet|number: the kind of the preset picked), bullet (dot|dash|circle|square), number (arabic|upperLetters|lowerLetters|upperRoman|lowerRoman|twoDigits|threeDigits), listAlign (left|right), listLevel (1..9; 1 for paragraphs in no list), leftIndent, firstLineIndent (pt)): the type shows the dialog's values while it is open, OK runs text.setFormat with the values that changed (and the indents it shows when a preset puts the paragraphs in a list), as one undo step, and Cancel puts the type back; a preset picked for paragraphs in no list whose indents are 0 fills in 18 and -18 pt; an error with no type selected or an unknown tab"), + ("type.convertListToText", "Convert to Text", "", "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type"), + ("align.horizontalLeft", "Horizontal Align Left", "", "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \\\"left\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), + ("align.horizontalCenter", "Horizontal Align Center", "", "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \\\"center\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), + ("align.horizontalRight", "Horizontal Align Right", "", "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \\\"right\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), + ("align.horizontalVerticalCenter", "Horizontal & Vertical Align Center", "", "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \\\"center\\\", vertical: \\\"center\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), + ("align.verticalTop", "Vertical Align Top", "", "{} the Align panel's Vertical Align Top button: object.align {vertical: \\\"top\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), + ("align.verticalCenter", "Vertical Align Center", "", "{} the Align panel's Vertical Align Center button: object.align {vertical: \\\"center\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), + ("align.verticalBottom", "Vertical Align Bottom", "", "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \\\"bottom\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), + ("align.distributeVerticalTop", "Vertical Distribute Top", "", "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \\\"top\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), + ("align.distributeVerticalCenter", "Vertical Distribute Center", "", "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \\\"center\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), + ("align.distributeVerticalBottom", "Vertical Distribute Bottom", "", "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \\\"bottom\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), + ("align.distributeHorizontalLeft", "Horizontal Distribute Left", "", "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \\\"left\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), + ("align.distributeHorizontalCenter", "Horizontal Distribute Center", "", "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \\\"center\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), + ("align.distributeHorizontalRight", "Horizontal Distribute Right", "", "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \\\"right\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), + ("align.distributeVerticalSpace", "Vertical Distribute Space", "", "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \\\"vertical\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), + ("align.distributeHorizontalSpace", "Horizontal Distribute Space", "", "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \\\"horizontal\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), + ("type.bold", "Bold", "", "{} the selected type (or the Type tool's selection) in its family's Bold face, or back to the regular face when it is bold; keeps italics. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+B"), + ("type.italic", "Italic", "", "{} the selected type (or the Type tool's selection) in its family's Italic (or Oblique) face, or back upright when it is italic; keeps the weight. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+I"), + ("type.highlightFontName", "Highlight Font Name", "Cmd+Alt+Shift+F", "{} show the Character panel if it is hidden and open its font family menu, whose search field takes the keyboard focus: type to search, Enter applies the highlighted font → {field: \\\"fontName\\\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first"), + ("type.highlightKerning", "Highlight Kerning", "Cmd+Alt+K", "{} show the Character panel if it is hidden and give its Kerning field the keyboard focus with its value selected: type a value and press Enter → {field: \\\"kerning\\\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first"), + ("type.highlightTracking", "Highlight Tracking", "", "{} show the Character panel if it is hidden and give its Tracking field the keyboard focus with its value selected: type a value and press Enter → {field: \\\"tracking\\\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first"), + ("type.underline", "Underline", "Cmd+Shift+U", "{} the Character panel's Underline button: underline the selected type (or the Type tool's selection), or remove the underline when the text the Character panel shows is underlined (text.setFormat {underline}) → underlined"), + ("type.lineComposer", "Toggle Line Composer", "Cmd+Alt+Shift+C", "{} the selected type's paragraphs (or those the Type tool's selection touches) use the Single-line Composer when they all use the Every-line Composer, else the Every-line Composer (text.setFormat {composer}) → \\\"singleLine\\\" | \\\"everyLine\\\""), + ("type.hyphenate", "Hyphenate", "Cmd+Alt+Shift+H", "{} the Paragraph panel's Hyphenate checkbox: hyphenation off for the selected type's paragraphs (or those the Type tool's selection touches) when they all hyphenate, else on (text.setFormat {hyphenate}) → hyphenate"), + ("effect.last", "Last Effect…", "Cmd+Alt+Shift+E", "{} open the dialog of the last effect applied"), + ("view.zoomIn", "Zoom In", "Cmd+=", "{}"), + ("view.zoomOut", "Zoom Out", "Cmd+-", "{}"), + ("view.fitArtboard", "Fit Artboard in Window", "Cmd+0", "{}"), + ("view.fitAll", "Fit All in Window", "Cmd+Alt+0", "{}"), + ("view.actualSize", "Actual Size", "Cmd+1", "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)"), + ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), + ("view.goToArtboard", "Go to Artboard", "", "{index: 0-based number | \\\"first\\\" | \\\"previous\\\" | \\\"next\\\" | \\\"last\\\"} make that artboard the status bar navigator's (the one Fit Artboard in Window and Actual Size show) and fit it in the window → {index}"), + ("view.firstArtboard", "First Artboard", "", "{} the status bar navigator's First Artboard button: view.goToArtboard {index: \\\"first\\\"} → {index}"), + ("view.previousArtboard", "Previous Artboard", "", "{} the navigator's Previous Artboard button: view.goToArtboard {index: \\\"previous\\\"} → {index}"), + ("view.nextArtboard", "Next Artboard", "", "{} the navigator's Next Artboard button: view.goToArtboard {index: \\\"next\\\"} → {index}"), + ("view.lastArtboard", "Last Artboard", "", "{} the navigator's Last Artboard button: view.goToArtboard {index: \\\"last\\\"} → {index}"), + ("view.edges", "Hide Edges", "Cmd+H", "{}"), + ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), + ("view.template", "Hide Template", "Cmd+Shift+W", "{} template layers left off the canvas (Show Template draws them again, dimmed as before) → shown"), + ("view.rulers", "Show Rulers", "Cmd+R", "{}"), + ("view.videoRulers", "Show Video Rulers", "", "{} the rulers show (as Show Rulers shows them) and count the active artboard in video pixels from its top-left corner, a pixel as wide as its Video Ruler Pixel Aspect Ratio says (artboard.setProps videoRulerPixelAspectRatio) and one point tall; again (Hide Video Rulers), they count in the General unit from the ruler origin as before → whether video rulers show"), + ("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"), + ("view.guides", "Hide Guides", "Cmd+;", "{}"), + ("view.smartGuides", "Smart Guides", "Cmd+U", "{}"), + ("view.grid", "Show Grid", "Cmd+'", "{}"), + ("view.snapToGrid", "Snap to Grid", "Cmd+Shift+'", "{}"), + ("view.snapToPoint", "Snap to Point", "Cmd+Alt+'", "{}"), + ("view.presentation", "Presentation Mode", "Shift+F", "{}"), + ("view.screenMode", "Screen Mode", "F", "{mode?: 0..2} (no param cycles)"), + ("view.rotate", "Rotate View", "", "{angle: degrees, clockwise on screen as the status bar shows it} turn the view to that angle about the center of the canvas; returns {angle}, normalized to (-180, 180]"), + ("view.rotateToSelection", "Rotate View to Selection", "", "{} turn the view about the center of the canvas to the angle the selected objects share (selectionRotation in document.inspect, 0 when they differ), so their bounding box is square to the window; returns {angle}"), + ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), + ("window.control", "Control", "", "{}"), + ("window.helpBar", "Help Bar", "", "{} toggle the tool-help strip independently of the status bar; returns the new visibility"), + ("window.control.dock", "Dock Control Bar", "", "{edge: \\\"top\\\"|\\\"bottom\\\"} dock the Control bar to the top of the window, under the menu bar, or to its bottom, as the bar's panel menu (Dock to Top, Dock to Bottom) and a drag of its gripper do; kept with the workspace; another edge is an error and changes nothing; returns {edge}"), + ("window.toolbar", "Tools", "", "{}"), + ("window.toolbarColumns", "Toolbar: Single/Double Column", "", "{double?: bool, toolbar?: name} show the toolbar's tools (with `toolbar`, that toolbar made with New Toolbar's) in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state"), + ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), + ("window.toolsBesideCanvas", "Keep Tools Beside the Canvas", "", "{on?: bool} dock the Tools panel between the left-side panels and the canvas (true) or at the window's left edge (false, the default), or toggle (omitted or null; any other value is an error); a floating Tools panel stays floating and docks at the chosen place when it is docked again. Kept with the workspace; a workspace saved without it docks Tools at the window's edge. Returns the new state"), + ("window.floatTools", "Float Tool Group", "", "{tool: id, floating?: bool, toolbar?: name} float the toolbar group holding `tool` (in the current Basic or Advanced layout; with `toolbar`, in that toolbar made with New Toolbar) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state"), + ("toolbar.show", "Toolbar: Show Controls", "", "{control?: \\\"fillStroke\\\"|\\\"coloring\\\"|\\\"drawMode\\\"|\\\"screenMode\\\", on?: bool} show (true), hide (false) or toggle (omitted) one of the controls under the toolbar's tools (Fill and Stroke, Coloring, Draw Mode, Screen Mode), as the All Tools drawer's Show section does; saved with the preferences; returns every control's state"), + ("window.editToolbar", "Edit Toolbar", "", "{open?: bool, toolbar?: name} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar (with `toolbar`, beside that toolbar made with New Toolbar, which shows), as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state"), + ("toolbar.addTool", "Toolbar: Add Tool", "", "{tool: id, slot?: n, place?: \\\"before\\\"|\\\"after\\\"|\\\"group\\\", toolbar?: name} put `tool` in the current (Basic or Advanced) toolbar (with `toolbar`, in that toolbar made with New Toolbar), moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)"), + ("toolbar.removeTool", "Toolbar: Remove Tool", "", "{tool: id, group?: bool, toolbar?: name} take `tool` (with `group`, its whole group) out of the current toolbar (with `toolbar`, out of that toolbar made with New Toolbar; one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots"), + ("toolbar.reset", "Toolbar: Reset", "", "{toolbar?: name} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes (with `toolbar`, make that toolbar made with New Toolbar blank again); returns its slots"), + ("window.toolbar.new", "New Toolbar…", "", "{name?: string} make a blank toolbar called `name`, of at most 200 characters (omitted: a dialog asks for the name), floating beside the Tools panel and listed under Window › Toolbars; its ••• button opens the All Tools drawer to fill it (window.editToolbar with `toolbar`); it is kept with the current workspace; returns {toolbar: name}"), + ("window.toolbar.custom", "Show Toolbar", "", "{name: string, show?: bool} show (true), hide (false) or toggle (omitted) the toolbar made with New Toolbar called `name`, as its item under Window › Toolbars does (its title bar's × hides it); shown after Tab hid the toolbars, it shows alone and the others are turned off; returns the new state"), + ("window.toolbar.manage", "Manage Toolbars…", "", "{} open the Manage Toolbars dialog, which lists the toolbars made with New Toolbar: a name typed for the selected one renames it, + copies it (none selected: makes a blank toolbar) and the trash can deletes it"), + ("window.toolbar.rename", "Rename Toolbar", "", "{name: string, to: string} give the toolbar made with New Toolbar called `name` the name `to` (not empty, at most 200 characters, not another toolbar's whatever the case), as typing a name in Manage Toolbars does; returns {toolbar: name}"), + ("window.toolbar.duplicate", "Duplicate Toolbar", "", "{name?: string} copy the toolbar made with New Toolbar called `name`, with its tools, as \\\"`name` Copy\\\" (omitted: make a blank toolbar called \\\"Toolbar N\\\"), as Manage Toolbars' + does; the copy shows and is kept with the current workspace; returns {toolbar: name}"), + ("window.toolbar.delete", "Delete Toolbar", "", "{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does"), + ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"), + ("window.taskBar", "Contextual Task Bar", "", "{}"), + ("window.pathfinder.compoundShapes", "Shape Mode Buttons Make Compound Shapes", "", "{} toggles the Compound Shapes add-on's setting: on, the Pathfinder panel's Shape Mode buttons make a compound shape and Alt-click gives the permanent Pathfinder result; off (the default), the other way round. Members of a compound shape selected always take the mode"), + ("window.taskBar.pin", "Pin Bar Position", "", "{pinned?: bool} keep the Contextual Task Bar where it is instead of following the selection (true), let it follow the selection again from where it is (false) or toggle (omitted), as the bar's More Options menu does; returns the new state"), + ("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"), + ("window.dock", "Panels", "Tab", "{} hide the panels, the toolbar and the Control bar, or show again the ones the last Tab hid (all three when none of them shows and Tab hid none); returns whether it showed them"), + ("window.panels", "Panels Except Tools and Control", "Shift+Tab", "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show"), + ("window.panel", "Show Panel", "", "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \\\"Layers\\\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was"), + ("window.panel.layout", "Change Panel Layout", "", "{action} serialized docking action, including floating geometry, divider sizes and accordion state"), + ("window.panel.move", "Move Panel", "", "{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel, group?: bool, placement?} move a panel (with `group`, its whole group) into the group holding `anchor`: as a tab (before the `before` panel; null appends), a split beside it, or (`above` / `below`) a group of its own above or below it in its dock column or floating window; `placement` (a serialized Placement) replaces zone and before"), + ("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"), + ("window.panel.close", "Close Panel", "", "{panel} hide a panel"), + ("window.panel.collapse", "Collapse Panel Group", "", "{panel, collapsed?: bool} minimize the group holding a panel to its tabs (in a floating window, the window to its title bar), or restore it, as a double-click on a tab or the drag bar does; the same as window.panel.minimize {panel, minimized}. Without `collapsed` it toggles. Returns {panel, collapsed, layout}"), + ("window.panel.float", "Float Panel", "", "{panel: id or \\\"tools\\\", x?, y?, width?, height?, group?: bool, column?: bool or n, onto?: id, above?: id, below?: id, collapsed?: bool} float a panel (with `group`, its whole group; with `column`, its whole dock column or floating window) as a floating window with its top-left corner at x, y in window points (default: inside the dock area, cascaded), as dragging its tab out does; a whole floating window keeps its rect unless given one. `onto` makes it a tab of the floating group holding that panel instead; `above` / `below` a group of its own above or below the group holding that panel, in its floating window or dock column. `collapsed` then minimizes (true) or restores (false) its window or group. A hidden panel shows first. \\\"tools\\\" floats the Tools panel at x, y. Returns {layout, closed, floating}"), + ("window.panel.dock", "Dock Panel", "", "{panel: id or \\\"tools\\\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \\\"tools\\\" docks the Tools panel at the left edge of the window, or beside the canvas inside any left-side dock while window.toolsBesideCanvas is on; with side: right (or End) on the right, between the canvas and the panels, as dropping it there or on the window's right edge does (default: the side it docked at last; left or Start docks it on the left), returning {panel, floating, pos, side}. Returns {layout, closed, floating}"), + ("window.collapseDock", "Collapse Panels to Icons", "", "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state"), + ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), + ("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …): the one left keeps its layout changes, and the one switched to comes back as it was left (as saved if never changed)"), + ("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace to the way it was saved, dropping the layout changes made in it"), + ("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"), + ("window.workspace.manage", "Manage Workspaces…", "", "{}"), + ("window.workspace.delete", "Delete Workspace", "", "{name}"), + ("window.workspace.rename", "Rename Workspace", "", "{name, to}"), + ("window.workspace.list", "List Workspaces", "", "{}"), + ("window.newWindow", "New Window", "", "{} open another view of the current document, with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own (titled with the view's number, \\\"Name:2\\\") or, when the current view floats, in a floating window; it becomes the current window; the Arrange commands treat each view as a window; file.close closes the current view and the last view closes the document; returns {index, view}"), + ("window.arrange.cascade", "Cascade", "", "{} put every open document in a floating window of its own and stack the windows from the upper left of the document area to its lower right, 30 pt apart, in tab order, each fitted to its window, the current document's in front; on the desktop each is an OS window, on the web and Wayland a window inside the app's window; returns {floating} (document indices)"), + ("window.arrange.tile", "Tile", "", "{} show every open document and each further view of one (window.newWindow) at once, edge to edge in the document area, one pane each in a grid (columns: the square root of the number of panes, rounded up), each fitted to its pane; the current window's pane takes the tools, a click in another pane makes its window the current one; the tabs stay; windows floating come back under the tabs first; with fewer than two windows open the view stays as it is; returns {tiled, panes}"), + ("window.arrange.floatInWindow", "Float in Window", "", "{index?, view?, at?: [x, y]} move the current window, or document `index`'s view `view` (its current view without one; it becomes the current window), out of the tab strip into a floating window of its own, fitted to it, with the window's top-left corner at `at` (main window points) or else at the next place of a cascade; dragging a document tab more than 24 pt above or below the tab strip does this at the pointer; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)"), + ("window.arrange.floatAllInWindows", "Float All in Windows", "", "{} move every document under the tab strip into a floating window of its own, at the next places of a cascade; returns {floating} (document indices)"), + ("window.arrange.consolidateAllWindows", "Consolidate All Windows", "", "{index?, view?} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}; with `index`, only document `index` (its view `view`, else its current view) goes back under its tab and becomes the current one, the other windows and Tile staying as they are, as when a document window is dragged by its title bar (an OS window by the tab at its top) onto the tab strip; returns {tiled, floating} (document indices)"), + ("window.nextDocument", "Next Document", "Cmd+F6", "{} make the open document after the current one in tab order the current one, as a click on its tab does (after the last document it goes to the first, and from the Home screen to the first); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view"), + ("window.previousDocument", "Previous Document", "Cmd+Shift+F6", "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view"), + ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), + ("tool.setOption", "Tool Option", "", "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen"), + ("effect.dialog", "Effect…", "", "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item), aligned?: bool (index and item are rows of the first selected object's stack, which OK edits in each object where it has them, as effect.setParams does)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \\\"effectExists\\\"}"), + ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), + ("ui.recolorDialog", "Recolor Artwork…", "", "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name; or \\\"document\\\" (Limit to Library; \\\"\\\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)"), + ("ui.cropImage", "Crop Image", "", "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \\\"rect\\\", value: [x, y, width, height]} sets the box"), + ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), + ("pathfinder.repeat", "Repeat Pathfinder", "Cmd+4", "{} repeat the last successful Pathfinder panel operation, direct object.pathfinder.* command or command.batch step on the selection with current Pathfinder Options; an error until one has run"), + ("file.export.pdf", "Save as PDF…", "", "{} opens the Save PDF dialog; with params = document.exportPdf options written to path (asked when missing; viewAfterSaving opens the file) → {path, bytes, warnings}"), + ("help.about", "About VectorCraft", "", "{}"), + ("help.checkForUpdates", "Check for Updates…", "", "{}"), + ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), + ("help.safeMode", "Safe Mode…", "", "{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)"), + ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), + ("ui.swatchOptions", "Swatch Options…", "", "{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit) or a gradient (the same dialog with `name` only; it shows the gradient), pattern editing for a pattern"), + ("ui.newSwatch", "New Swatch…", "", "{spot?, global?, group?: colour group name} open New Swatch for the active fill or stroke (dialog `newSwatch`, engine: swatch.new); a color opens as a spot color with `spot`, and as a global process color with `global` alone"), + ("ui.newColorGroup", "New Color Group…", "", "{swatches?: [names]} open New Color Group, from those swatches or the selected artwork (dialog `newColorGroup`, engine: swatch.newGroup)"), + ("ui.colorPicker", "Color Picker…", "", "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \\\"#rrggbb\\\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); RGB, HSB and hex values use the working RGB space; OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard"), + ("ui.sampleScreenColor", "Sample Color from Screen", "", "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open"), + ("ui.newBrush", "New Brush…", "", "{ids?} (default: the selected art) open New Brush, what the Brushes panel's New Brush button and art dropped on the panel open (dialog `newBrush`, field `type`: calligraphic|scatter|art|bristle|pattern, Art when there is art, else Calligraphic; scatter, art and pattern need art, else OK gives the error `nothing selected`); OK opens that type's Brush Options for the new brush (dialog `brushOptions` with the fields of ui.brushOptions and `__create`; name: New Brush, numbered when taken; the `ids` given in `__ids`), whose OK runs brush.new {type, name, params, ids?} (a name another brush has gets a number) and whose Cancel adds no brush"), + ("ui.brushOptions", "Brush Options…", "", "{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift, keyColor, and tips: true opens Colorization Tips (what each method does, beside a sample of the brush painted with it), as the Tips button beside Method does; OK runs brush.options"), + ("ui.graphicStyleOptions", "Graphic Style Options…", "", "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)"), + ("ui.charStyleOptions", "Character Style Options", "", "{name} open Character Style Options (dialog `charStyleOptions`); OK runs charStyle.edit with name, to and sparse attrs in one undo step; Cancel restores a live preview"), + ("ui.charStyleOptions.resetPanel", "Reset Panel", "", "{} clear the settings of the section shown, Basic Character Formats, Advanced Character Formats, Character Color or OpenType Features, in the open Character Style Options or New Character Style dialog; available only on those sections"), + ("ui.newCharStyle", "New Character Style…", "", "{} open New Character Style (dialog `newTextStyle`, field `name`, filled in with the name charStyle.new {} would give the style), what Alt-clicking the Character Styles panel's New button opens; OK runs charStyle.new with the name and the Type tool's selected characters (else the selected type) as they were when the dialog opened; Cancel adds no style"), + ("ui.newParaStyle", "New Paragraph Style…", "", "{} open New Paragraph Style (dialog `newTextStyle`, field `name`, filled in with the name paraStyle.new {} would give the style), what Alt-clicking the Paragraph Styles panel's New button opens; OK runs paraStyle.new with the name and the paragraph of the Type tool's selection (else the selected type) as it was when the dialog opened; Cancel adds no style"), + ("ui.symbolOptions", "Symbol Options…", "F8", "{name?} open Symbol Options (dialog `symbolOptions`, fields `name`, `exportType`: movieClip|graphic, `registration` 0..8). Without `name` and with a selection: New Symbol (default name the first free \\\"New Symbol\\\"/\\\"New Symbol N\\\", Export Type Movie Clip, Registration centre); OK runs symbol.new {name, exportType, registration}. With `edit: true` or `name`: edit that symbol; OK runs symbol.options {name, newName?, exportType, registration}"), + ("tool.options", "Tool Options…", "", "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \\\"flare\\\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil and paintbrush, the same at any zoom; pt for smooth and blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \\\"livePaintSelection\\\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \\\"livePaintBucket\\\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \\\"shapeBuilder\\\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \\\"objectsOnPath\\\", values}: the objects attached next use them, groups attached before keep theirs)"), + ("ui.colorGuideOptions", "Color Guide Options…", "", "{} open Color Guide Options (dialog `colorGuideOptions`, fields `steps` 1–20 and `amount` 0–100): OK sets the Color Guide panel's variation grid (read back with `ui.inspect`: ui.color_guide; engine: color.harmony)"), + ("ui.colorBalanceDialog", "Adjust Color Balance…", "", "{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray / tint (global) −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step"), + ("ui.saturateDialog", "Saturate…", "", "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step"), + ("window.swatchLibrary", "Swatch Library", "", "{library: id or name (see swatch.library.list), or the id of a persistent panel's missing library | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}"), + ("window.swatchLibrary.other", "Other Library…", "", "{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in its library panel"), + ("ui.libraryPanel", "Library Panel Options", "", "{panel: a library panel's id (`library:swatches:pastels`, as window.swatchLibrary answers), library?: id or name of another library of its kind to show in the panel (Previous / Next Library; one open in a panel of its own is brought forward instead), view?: smallThumb|mediumThumb|largeThumb|smallList|largeList, showFind?: bool, persistent?: bool (reopen the panel when the app starts)} set an open library panel, or a closed persistent one's view, Show Find Field and Persistent; kept with the preferences (ui.inspect: ui.library_panels) → {panel, kind, library, view, showFind, persistent, open}"), + ("ui.libraryPanel.locate", "Locate Library…", "", "{panel: an open library panel's id, path?} (default: pick a file) Locate Library… of a persistent library panel whose library's file is missing: load the library from the file, as Other Library… does, and show it in the panel, which keeps its view, Show Find Field and Persistent. When the file's library has a panel open already, the panel closes and that one is brought forward, made persistent when the closed one was → what ui.libraryPanel answers"), + ("ui.saveSwatchLibrary", "Save Swatch Library…", "", "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|ase|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save"), + ("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"), + ("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"), + ("window.userSwatchLibrary3", "User Swatch Library 3", "", "{} open the 3. User Defined swatch library"), + ("window.userSwatchLibrary4", "User Swatch Library 4", "", "{} open the 4. User Defined swatch library"), + ("window.userSwatchLibrary5", "User Swatch Library 5", "", "{} open the 5. User Defined swatch library"), + ("window.userSwatchLibrary6", "User Swatch Library 6", "", "{} open the 6. User Defined swatch library"), + ("window.userSwatchLibrary7", "User Swatch Library 7", "", "{} open the 7. User Defined swatch library"), + ("window.userSwatchLibrary8", "User Swatch Library 8", "", "{} open the 8. User Defined swatch library"), + ("window.userSwatchLibrary9", "User Swatch Library 9", "", "{} open the 9. User Defined swatch library"), + ("window.userSwatchLibrary10", "User Swatch Library 10", "", "{} open the 10. User Defined swatch library"), + ("ui.mergeGraphicStyles", "Merge Graphic Styles…", "", "{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)"), + ("ui.layerOptions", "Options for Selection…", "", "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \\\"Light Blue\\\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step); for several rows it sends only the fields set in the dialog"), + ("ui.rename", "Rename", "", "{id?} open the Layers panel's name field on the row of layer, group or object id, as double-clicking the row's name does (the panel is shown and the layers around the row open): Enter renames it with layer.setProps (one undo step), Escape keeps the name. Without id: the highlighted row when a Layers panel row was clicked last, else the one selected object or group, else the one highlighted row, else the current layer. Several rows or objects, or a row the panel's search, filter or Show Layers Only hides, is an error and nothing opens → {id, name}"), + ("ui.newLayer", "New Layer Options…", "Cmd+Alt+L", "{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields"), + ("ui.newArtboard", "New Artboard Options…", "", "{} open Artboard Options for a new artboard, filled in as artboard.new {} would make it (Artboard N, the last artboard's size, 20 pt to its right): dialog `artboardOptions` with new: true, fields name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.new with the fields and makes the new artboard the active one; Cancel adds nothing"), + ("artboard.selectAll", "Select All Artboards", "", "{} the Artboard tool, chosen first when another tool is in use, selects every artboard, and the active artboard stays active; the art's selection stays as it is, and no undo step is recorded → {selected: [index…]} (the active artboard first)"), + ("ui.layersPanelOptions", "Panel Options…", "", "{} open the Layers panel's Panel Options: dialog `layersPanelOptions`, fields layersOnly, locateSelected (Locate Selected Objects, off by default: when the last selected object changes outside the panel, on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as after layer.locate, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off; a change made during a drag is located when the drag ends), rowSize (small|medium|large|other), otherSize (12–100 pt), thumbLayers, thumbGroups, thumbObjects; OK applies them (kept with the UI state)"), + ("ui.navigatorPanelOptions", "Panel Options…", "", "{color?: #rrggbb or a preset name (Light Red, the default, then the layer colors such as \\\"Light Blue\\\"), solidDashes?: bool (Draw Dashed Lines as Solid Lines, off by default: the Navigator's thumbnail draws dashed strokes without their dashes; the canvas, printing and exports keep them)} set the Navigator panel's options, the color its view box is drawn in and solid dashes (kept with the UI state) → {color, solidDashes}; with neither, open its Panel Options (dialog `navigatorPanelOptions`, fields color, solidDashes; OK sets them); an error for a value that is not a color or not true or false, which changes nothing"), + ("ui.layersExpand", "Expand Layers Panel Rows", "", "{ids?: [id…] (default: every row that holds others), open?: bool (default true)} open or close rows of the Layers panel, as clicking their triangles does → {count}"), + ("ui.layersFilter", "Filter Layers Panel Rows", "", "{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}"), + ("ui.tileEdgeColor", "Tile Edge Color…", "", "{} open Tile Edge Color (dialog `tileEdgeColor`, field `color`: #rrggbb or a preset name such as \\\"Light Blue\\\"); OK sets the preference patternTileEdgeColor (prefs.set), the colour of the tile edge and swatch bounds in pattern editing mode; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing"), + ("ui.flattenTransparencyDialog", "Flatten Transparency…", "", "{} open Flatten Transparency for the selection (dialog `flattenTransparency`, fields `preset` (a preset name: setting it loads its options), balance 0..100, lineArtPpi, gradientPpi 1..2400, textToOutlines, strokesToOutlines, clipComplexRegions, antiAlias, preserveAlpha, preserveOverprints, `preview` (off at first)): OK runs object.flattenTransparency with those options as one undo step"), + ("ui.flattenerPresetsDialog", "Transparency Flattener Presets…", "", "{selected?: preset name} open the presets manager (dialog `flattenerPresets`, fields `selected`, then the selected preset's `name` and option keys: setting them on a saved preset saves it, a new `name` renames it; built-in presets don't change). New, Delete, Import… and Export… run flattener.presets.save / delete / import / export"), + ("ui.imageTracePresetDialog", "Save as New Preset…", "", "{params?: tracing settings (default: the Default preset's), rename?: a saved preset's name} the Image Trace panel's Manage Presets: open Save as New Preset… for those settings, or with `rename`, Rename… for that saved preset (dialog `imageTracePreset`, field `name`: a new \\\"Tracing Preset N\\\" or the preset's name); OK runs imageTrace.presets.save with the name (as `newName` when renaming), and the panel shows the preset it made from its settings"), + ("ui.flattenerPreview", "Flattener Preview", "", "{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it"), + ("window.graphicStyleLibrary", "Graphic Style Library", "", "{library: id or name (see graphicStyle.libraries), or the id of a persistent panel's missing library | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}"), + ("window.graphicStyleLibrary.other", "Other Library…", "", "{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in its library panel"), + ("ui.saveGraphicStyleLibrary", "Save Graphic Style Library…", "", "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary"), + ("ui.loadCharStyles", "Load Character Styles…", "", "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character styles to the active one (engine: charStyle.load), the Character Styles panel menu's Load Character Styles…"), + ("ui.loadParaStyles", "Load Paragraph Styles…", "", "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's paragraph styles to the active one (engine: paraStyle.load), the Paragraph Styles panel menu's Load Paragraph Styles…"), + ("ui.loadAllStyles", "Load All Styles…", "", "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character and paragraph styles to the active one (engine: textStyle.loadAll), Load All Styles… in either panel's menu"), + ("window.userGraphicStyleLibrary1", "User Graphic Style Library 1", "", "{} open the 1. User Defined graphic style library (graphicStyle.libraries, category user)"), + ("window.userGraphicStyleLibrary2", "User Graphic Style Library 2", "", "{} open the 2. User Defined graphic style library"), + ("window.userGraphicStyleLibrary3", "User Graphic Style Library 3", "", "{} open the 3. User Defined graphic style library"), + ("window.userGraphicStyleLibrary4", "User Graphic Style Library 4", "", "{} open the 4. User Defined graphic style library"), + ("window.userGraphicStyleLibrary5", "User Graphic Style Library 5", "", "{} open the 5. User Defined graphic style library"), + ("window.userGraphicStyleLibrary6", "User Graphic Style Library 6", "", "{} open the 6. User Defined graphic style library"), + ("window.userGraphicStyleLibrary7", "User Graphic Style Library 7", "", "{} open the 7. User Defined graphic style library"), + ("window.userGraphicStyleLibrary8", "User Graphic Style Library 8", "", "{} open the 8. User Defined graphic style library"), + ("window.userGraphicStyleLibrary9", "User Graphic Style Library 9", "", "{} open the 9. User Defined graphic style library"), + ("window.userGraphicStyleLibrary10", "User Graphic Style Library 10", "", "{} open the 10. User Defined graphic style library"), + ("window.symbolLibrary", "Symbol Library", "", "{library: id or name (see symbol.libraries) | null (close)} open a symbol library in a library panel of its own (UI state `library_panels`, kind symbols); clicking a symbol there runs symbol.addFromLibrary, dragging one onto the canvas runs it with place → {open, panel, name, count}"), + ("window.symbolLibrary.other", "Other Library…", "", "{path?} (default: pick a file) load the symbols of another document or a saved symbol library (engine: symbol.loadLibrary), and open them in a library panel"), + ("ui.saveSymbolLibrary", "Save Symbols…", "", "{names?: [the symbol selected in the Symbols panel]} open Save Symbol Library (dialog `saveSymbolLibrary`: name, user: save to the user library folder, selectedOnly); OK runs symbol.saveLibrary"), + ("window.userSymbolLibrary1", "User Symbol Library 1", "", "{} open the 1. User Defined symbol library (symbol.libraries, category user)"), + ("window.userSymbolLibrary2", "User Symbol Library 2", "", "{} open the 2. User Defined symbol library"), + ("window.userSymbolLibrary3", "User Symbol Library 3", "", "{} open the 3. User Defined symbol library"), + ("window.userSymbolLibrary4", "User Symbol Library 4", "", "{} open the 4. User Defined symbol library"), + ("window.userSymbolLibrary5", "User Symbol Library 5", "", "{} open the 5. User Defined symbol library"), + ("window.userSymbolLibrary6", "User Symbol Library 6", "", "{} open the 6. User Defined symbol library"), + ("window.userSymbolLibrary7", "User Symbol Library 7", "", "{} open the 7. User Defined symbol library"), + ("window.userSymbolLibrary8", "User Symbol Library 8", "", "{} open the 8. User Defined symbol library"), + ("window.userSymbolLibrary9", "User Symbol Library 9", "", "{} open the 9. User Defined symbol library"), + ("window.userSymbolLibrary10", "User Symbol Library 10", "", "{} open the 10. User Defined symbol library"), + ("window.brushLibrary", "Brush Library", "", "{library: id or name (see brush.libraries) | null (close)} open a brush library in a library panel of its own (UI state `library_panels`, kind brushes); clicking a brush there runs brush.addFromLibrary (with apply when paths are selected), dragging one onto a path runs it with apply on that path, dragging one onto the Brushes panel adds it → {open, panel, name, count}"), + ("window.brushLibrary.other", "Other Library…", "", "{path?} (default: pick a file) load the brushes of another document or a saved brush library (engine: brush.loadLibrary), and open them in a library panel"), + ("ui.saveBrushLibrary", "Save Brushes…", "", "{names?: [the brushes selected in the Brushes panel]} open Save Brush Library (dialog `saveBrushLibrary`: name, user: save to the user library folder, selectedOnly); OK runs brush.saveLibrary"), + ("window.userBrushLibrary1", "User Brush Library 1", "", "{} open the 1. User Defined brush library (brush.libraries, category user)"), + ("window.userBrushLibrary2", "User Brush Library 2", "", "{} open the 2. User Defined brush library"), + ("window.userBrushLibrary3", "User Brush Library 3", "", "{} open the 3. User Defined brush library"), + ("window.userBrushLibrary4", "User Brush Library 4", "", "{} open the 4. User Defined brush library"), + ("window.userBrushLibrary5", "User Brush Library 5", "", "{} open the 5. User Defined brush library"), + ("window.userBrushLibrary6", "User Brush Library 6", "", "{} open the 6. User Defined brush library"), + ("window.userBrushLibrary7", "User Brush Library 7", "", "{} open the 7. User Defined brush library"), + ("window.userBrushLibrary8", "User Brush Library 8", "", "{} open the 8. User Defined brush library"), + ("window.userBrushLibrary9", "User Brush Library 9", "", "{} open the 9. User Defined brush library"), + ("window.userBrushLibrary10", "User Brush Library 10", "", "{} open the 10. User Defined brush library"), + ("ui.expandDialog", "Expand…", "", "{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step; dimmed, and an error, while the selection has an appearance effect.expandAppearance expands (object.expand.info's appearance)"), + ("attributes.openUrl", "Browser", "", "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button; http, https and mailto URLs only; not over the control channel or MCP) → {url}"), + ("ui.javascriptFilesDialog", "Link JavaScript Files…", "", "{} open JavaScript Files for the active document (dialog `javascriptFiles`, fields `file`: a path or URL and `selected`: a linked file; the SVG Interactivity panel's Link JavaScript Files button): its list shows svgInteractivity.info's files, Add runs svgInteractivity.addFile with file and Remove runs svgInteractivity.removeFile with selected"), + ("ui.svgFilterDialog", "Apply SVG Filter…", "", "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \\\"\\\", \\\"new\\\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's filters; OK runs svgFilter.apply with selected, or effect.setParams on the applied effect; New and Edit open the code, Save runs svgFilter.define, Delete runs svgFilter.delete and Import… svgFilter.import with a picked SVG file"), + ("ui.importSvgFilter", "Import SVG Filter…", "", "{} pick an SVG file and add its filters to the active document's SVG filters (svgFilter.import) → svgFilter.import's result"), + ("ui.spotColors", "Spot Colors…", "", "{} open Spot Colors (dialog `spotColors`, field `useLab`: true shows and separates Lab spot colours from their Lab values, false from their CMYK equivalents); OK runs swatch.spotOptions as one undo step"), + ("ui.menuDialog", "Menu Dialog", "", "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|path.averageJoin|object.path.offsetPath|object.path.simplify|object.path.smooth|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|artboard.rename|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options|addon.arrange.grid|addon.arrange.replace|addon.arrange.size} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, renameArtboards, envelopeWarp, envelopeMesh, envelopeOptions, arrangeGrid, arrangeReplace, arrangeSize; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)"), + ("ui.widthPointEdit", "Width Point Edit…", "", "{id, index?: existing width point, t?: 0..1 for a new point, adjustAdjoining?: bool (false)} open Width Point Edit on path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking the stroke opens it too, Shift on a width point enabling adjoining adjustment): OK runs stroke.widthPoint.set; discard: true (Delete) removes an existing point or discards a prospective point"), + ("ui.corners", "Corners…", "", "{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape"), + ("ui.simplifyAuto", "Auto-Simplify", "", "{} the open Simplify slider (Object › Path › Simplify opens it: dialog `simplifySlider` with field `level` 0..100, object.path.simplify's level, opening at the level object.path.simplify {auto: true} picks for the selection, previewed on the canvas until ui.dialog.confirm keeps it as one undo step; after OK in the Simplify dialog with `retain` ticked, the menu item opens the dialog `simplify` with the fields of that OK instead) moves to the level object.path.simplify {auto: true} picks for the selection and previews it → {level}; an error when the slider isn't open"), + ("ui.smoothAuto", "Auto-Smooth", "", "{} the open Smooth slider (Object › Path › Smooth opens it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open"), + ("ui.simplifyMoreOptions", "More Options", "", "{} the Simplify dialog (dialog `simplify`: tolerance (pt), angleThreshold, straightLines, showOriginal, retain: ticked, OK makes Object › Path › Simplify open this dialog with these fields next time) takes the open Simplify slider's place, at the slider's tolerance → {tolerance}; an error when the slider isn't open"), + ("ui.dialogReset", "Reset", "", "{} the open dialog's Reset, which its Cancel button reads while Alt (Option on macOS) is held (Flare Tool Options, dialog `flareOptions`): its fields go back to their defaults and the dialog stays open, so OK applies them → the dialog; an error when no dialog is open or the open one has no Reset"), + ("ui.justification", "Justification…", "", "{} open Justification (the Paragraph panel menu's Justification…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, with Preview (dialog `justification`: wordMin, wordDesired, wordMax 0..1000, letterMin, letterDesired, letterMax -100..500, glyphMin, glyphDesired, glyphMax 50..200 (percent), autoLeading 0..500 (percent), singleWord (fullJustify|alignLeft|alignCenter|alignRight), preview): OK runs text.setFormat with the values that changed, as one undo step, and is disabled while a row's Minimum exceeds its Desired or Desired its Maximum; an error with no type selected"), + ("ui.hyphenation", "Hyphenation…", "", "{} open Hyphenation (the Paragraph panel menu's Hyphenation…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows (dialog `hyphenation`: hyphenate, wordsLongerThan 1..25, afterFirst 1..15, beforeLast 1..15, hyphenLimit 0..25 (0: no limit), hyphenationZone (pt), hyphenateCapitalized): OK runs text.setFormat with the values that changed, as one undo step; an error with no type selected"), + ("ui.tateChuYokoSettings", "Tate-chu-yoko Settings…", "", "{} open Tate-chu-yoko Settings (the Character panel menu's Tate-chu-yoko Settings…, with Show East Asian Options) on the characters the Character panel acts on, filled in with the values they share (dialog `tateChuYokoSettings`: tcyUpDown, tcyLeftRight (pt, -1296..1296)): OK runs text.setFormat (text.setRangeStyle while the Type tool edits text) with the values that changed, as one undo step; an error with no type selected"), + ("ui.colorGuideLimit", "Limit Color Guide to Library", "", "{library: swatch library id or name (see swatch.library.list) | \\\"document\\\" (the document's swatches) | \\\"\\\" or null (no limit)} limit the Color Guide panel's colours to that library: every harmony colour and variation snaps to its nearest colour, as color.harmony {limitTo} answers (ui.inspect: ui.color_guide_limit) → {limitTo, name}"), + ("ui.colorPanel", "Color Panel Options", "", "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences; RGB, HSB, Web Safe RGB and hex values use the working RGB space (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}"), + ("ui.savePdfDialog", "Save PDF Dialog", "", "{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)"), + ("file.exportAs", "Export As…", "", "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}"), + ("ui.fileInfoDialog", "File Info Dialog", "", "{} open File Info (dialog `fileInfo`: the file.info fields, plus __keyword, keywords typed but not added yet); OK runs file.info. The File Info… menu item opens it too"), + ("ui.rasterEffectsSettingsDialog", "Document Raster Effects Settings Dialog", "", "{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too"), + ("ui.effectGalleryDialog", "Effect Gallery Dialog", "", "{} open Effect Gallery (dialog `effectGallery`: browse raster filters, build a stack, OK applies it as separate effects in one undo step). Effect → Effect Gallery… opens it too"), + ("ui.pdfPresetsDialog", "PDF Presets…", "", "{selected?: preset name} open Edit → PDF Presets (dialog `pdfPresets`, field `selected`): the built-in presets (read-only) and the saved ones, with the selected one's description and settings. New… and Edit… open the preset editor (ui.pdfPresetDialog); Delete, Import… and Export… run pdf.preset.delete / import / export"), + ("ui.pdfPresetDialog", "PDF Preset", "", "{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets"), + ("file.openRecent11", "Open Recent File 11", "", "{}"), + ("file.openRecent12", "Open Recent File 12", "", "{}"), + ("file.openRecent13", "Open Recent File 13", "", "{}"), + ("file.openRecent14", "Open Recent File 14", "", "{}"), + ("file.openRecent15", "Open Recent File 15", "", "{}"), + ("file.openRecent16", "Open Recent File 16", "", "{}"), + ("file.openRecent17", "Open Recent File 17", "", "{}"), + ("file.openRecent18", "Open Recent File 18", "", "{}"), + ("file.openRecent19", "Open Recent File 19", "", "{}"), + ("file.openRecent20", "Open Recent File 20", "", "{}"), + ("file.openRecent21", "Open Recent File 21", "", "{}"), + ("file.openRecent22", "Open Recent File 22", "", "{}"), + ("file.openRecent23", "Open Recent File 23", "", "{}"), + ("file.openRecent24", "Open Recent File 24", "", "{}"), + ("file.openRecent25", "Open Recent File 25", "", "{}"), + ("file.openRecent26", "Open Recent File 26", "", "{}"), + ("file.openRecent27", "Open Recent File 27", "", "{}"), + ("file.openRecent28", "Open Recent File 28", "", "{}"), + ("file.openRecent29", "Open Recent File 29", "", "{}"), + ("file.openRecent30", "Open Recent File 30", "", "{}"), + ("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"), + ("file.cancelExport", "Cancel Export", "", "{} stop every export running in the background (Background Export): an export stops between artboards and writes no file once cancelled → {cancelled: n}"), + ("file.exportInBackground", "Continue in Background", "", "{} close the export progress dialog; the exports go on, shown in the status bar → {hidden: n}"), + ("file.cancelOpen", "Cancel Opening", "", "{} stop opening every file being read in the background (its progress window's Cancel): the files don't open → {cancelled: n}"), + ("file.openInBackground", "Continue in Background", "", "{} close the progress window of the files being opened; they go on being read, shown in the status bar → {hidden: n}"), + ("ui.dxfOptionsDialog", "DXF Options Dialog", "", "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last, a dxfPreset's options under them; the dxfPreset field shows cnc while every field it sets matches, else custom); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too"), + ("links.editOriginal", "Edit Original", "", "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads, and not over the control channel or MCP); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update"), + ("links.reveal", "Show in Folder", "", "{id?} show the file of linked image `id` (default: the selected linked image) in the system's file manager → {path}"), + ("ui.placementOptionsDialog", "Placement Options…", "", "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions"), + ("ui.relinkDialog", "Relink…", "", "{ids?: [image or placed document ids]} open Relink for `ids` (default: the selected ones; dialog `relink`: ids, links ([{name, ids}], one row per linked file), scope (Replace: each|selected (default)|all|missingInFolder, see links.relink), path (picked when empty), paths (with each: the files chosen so far, one per row of links)); OK runs links.relink with them, one undo step, except with each: OK takes path, or picks a file, for the next row of links, and the last row's OK relinks them all. An error when nothing is given or selected"), + ("ui.findLinksInFolder", "Find Linked Files in Folder…", "", "{folder?, ids?: [image or placed document ids]} the Links panel's Find in Folder…: asks for a folder (or takes folder), then opens Find Linked Files (dialog `findLinks`) looking in it and its subfolders for the files of linked objects `ids` (default: the selected linked images and placed documents, else every missing file; links.findFiles). An error when there is nothing to look for. Disabled where there is no folder picker (the web)"), + ("ui.packageDialog", "Package…", "Cmd+Alt+Shift+P", "{} open Package for the saved document (dialog `package`: folder, name, copyLinks, linksFolder, relink, copyFonts, report; a document never saved asks to Save As first); OK saves unsaved changes, runs file.package (the web downloads the zip) and offers file.showPackage"), + ("ui.action.rename", "Rename Action", "", "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, or an empty name, is an error and renames nothing → {name}"), + ("ui.action.stepsIncluded", "Include Action Steps", "", "{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing → {set, action?, step?, included}"), + ("ui.action.new", "New Action", "", "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \\\"\\\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, or a key or color that is not one of these, it is an error and nothing is added → {set, action}"), + ("ui.action.options", "Action Options", "", "{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \\\"\\\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, or a key or color that is not one of these is an error and changes nothing → {name, key, color}"), + ("ui.action.play", "Play Action", "", "{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. An action with a stop, at any speed, plays up to the stop and waits until ui.action.continue or ui.action.stop; at Accelerated the steps between stops are one undoable batch each → {results, pending, atStop?: true when it already waits}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays"), + ("ui.action.playbackOptions", "Playback Options", "", "{speed?: accelerated|step|pause, seconds?: 1-60} set how the Actions panel plays actions: accelerated (the default) plays every step at once, step plays one step at a time and draws the window after each, and pause waits seconds (a whole number, 1 to 60, VectorCraft's own range) between steps. A param left out keeps its value; the setting is kept with the UI preferences. Any other speed or seconds is an error and changes nothing. The panel menu's Playback Options… (dialog `actionPlayback` with speed and seconds) runs this on OK → {speed, seconds}"), + ("ui.action.record", "Begin Recording", "", "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set or an existing recording is an error and changes nothing → {set, name}"), + ("ui.action.stop", "Stop Playing/Recording", "", "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. With nothing recording or playing it changes nothing → {stopped}"), + ("ui.action.insertStop", "Insert Stop", "", "{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, or no action and no recording is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}"), + ("ui.action.continue", "Continue Action", "", "{} play on from the stop the Actions panel's playing action waits at, on the document active now, and close the stop's message box (dialog `actionStopMessage`): the next step runs at once, or at Accelerated the steps up to the next stop as one undo step. The panel's Play Current Selection runs this while an action waits. With no action waiting at a stop it is an error → {results, pending, atStop?}"), + ("ui.action.deleteStep", "Delete Action Step", "", "{set: index, action: index, step: index} delete one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step). The steps after it keep whether they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error and changes nothing. Undo does not bring the step back. The panel's Delete Selection runs this for the selected step → {set, action, step}"), + ("ui.batchDialog", "Batch…", "", "{set?: index, action?: index} open Batch (dialog `actionBatch`) for the action at set/action (default: the first action of the first set); OK runs action.batch with the dialog's source, destination and overrides"), + ("ui.actionSet.save", "Save Actions…", "", "{set?: index, id?, path?} write the Actions panel's set at set (0 is the top set; default: the current set) with its actions to an action set file (.vcactions) at path, else a picked file named after the set (the web downloads it). id holds the panel's own set id, which its menu passes: the set with this id is saved wherever it is now, and set is not read. A set over 8 MiB as a file, with more than 1,000 actions or with an action of more than 10,000 steps is not written; the panel menu's Save Actions… → {path}"), + ("ui.actionSet.load", "Load Actions…", "", "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \\\"not an action set file\\\""), + ("ui.actionSet.delete", "Delete Action Sets", "", "{sets?: [index], ids?: [id]} delete the Actions panel's sets at these indexes (0 is the top set), with their actions (default: the current set). ids holds the panel's own set ids, which its question passes: the sets with these ids are deleted wherever they are now, sets is not read, and when one of them no longer exists nothing is deleted. The panel's Delete Selection on selected sets (Shift-click adds a range) asks first, and OK runs this. A recording into a deleted set stops and keeps nothing. An index past the last set is an error → {deleted: n}"), + ("ui.actionSet.duplicate", "Duplicate Action or Action Set", "", "{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \\\" copy\\\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action is an error → {set, action?}"), + ("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"), + ("docInfo.save", "Save Document Info…", "", "{path?, selectionOnly?, category?} write Document Info's text report (document.info {format: \\\"text\\\"}) to path, else a picked file (the web downloads it); with category (a document.info category id) only that category, else every category. The panel menu's Save… passes the category the panel shows and its Selection Only → {path}"), + ("ui.epsOptionsDialog", "EPS Options Dialog", "", "{path?, useArtboards?, range?, …document.exportEps options} open EPS Options (dialog `epsOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → EPS opens it too"), + ("file.saveForWeb", "Save for Web (Legacy)…", "Cmd+Alt+Shift+S", "{} open Save for Web (dialog `saveForWeb`: fields = the webExport.settings settings, `preset` loads a preset, __view: original|optimized|2up|4up, __zoom, __kbps; Save… remembers the settings and writes the files, Done only remembers them); with settings = document.exportForWeb, written to path, else a picked file (the web downloads the files) → {path, files, bytes}"), + ("file.saveForWeb.browser", "Preview in Browser", "", "{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}"), + ("file.exportSelection", "Export Selection…", "", "{multiple?: false} collect the selected objects as assets (assets.add: one of them all, as one image; true: one per object) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead"), + ("css.copy", "Copy Selected Style", "", "{scope?: selection|all (default selection), …css.selection options} copy the CSS of the selection (css.selection; all: css.generate) to the clipboard (CSS Properties panel) → {css, rules: n}"), + ("css.exportFile", "Export CSS…", "", "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}"), + ("file.print", "Print…", "Cmd+P", "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {preset? (a print preset's name: its settings instead of the document's), settings? (over the document's or the preset's), printer? (print.printers name; \\\"\\\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way"), + ("print.printers", "Printers", "", "{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}"), + ("print.printerSetup", "Printer Setup…", "", "{printer?} open the system's settings of the printer (the Print dialog's Setup…; desktop)"), + ("ui.printPresetsDialog", "Print Presets…", "", "{selected?: preset name} open Edit → Print Presets (dialog `printPresets`, field `selected`): [Default] (protected) and the saved presets, with how the selected one differs from [Default]. New… and Edit… open the preset editor (ui.printPresetDialog); Delete, Import… and Export… run print.presets.delete / import / export"), + ("ui.printPresetDialog", "Print Preset", "", "{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets"), + ("plugin.dialog", "Plug-in…", "", "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)"), + ("ui.installPlugin", "Install Plug-in…", "", "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \\\"pluginInstall\\\"} while it asks about permissions or a heavy budget | {pending: \\\"confirm\\\"} while it asks only about replacing"), + ("plugin.cancelRuns", "Cancel Plug-in Run", "", "{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}"), + ("plugin.hideRuns", "Continue in Background", "", "{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}"), + ("imageTrace.cancelRuns", "Cancel Image Trace", "", "{} stop every Image Trace running in the background (its progress window's Cancel): it stops at its next colour and changes nothing → {cancelled: n}"), + ("imageTrace.hideRuns", "Continue in Background", "", "{} close the progress window of the Image Traces running in the background; they go on, shown in the status bar → {hidden: n}"), + ("ui.runScript", "Other Script…", "Cmd+F12", "{path? | source?, name?} run a .js or .jsx file (script.run), after the warning unless Preferences › General › Show External Script Warning is off (dialog `scriptRun`: `name`, `script`, `dontShow`); without a file pick one (the web opens the browser's file picker)"), + ("ui.checkScript", "Check Script…", "", "{path? | data? | dataBase64?, name?, includes?: true with a path} check a script written for the reference app without running it (script.check; with a path, the files its #include lines name are checked too) and show the report (dialog `scriptCheck`: `report`, script.check's result; Copy Report copies it as JSON); without a file pick one (the web opens the browser's file picker; File › Open of a .jsx, .jsxinc or .jsxbin file shows the report too)"), + ("ui.perspectiveGridDialog", "Define Perspective Grid…", "", "{} open View › Perspective Grid › Define Grid (dialog `perspectiveGrid`, prefilled from the grid: the fields of perspective.grid.define); OK runs perspective.grid.define"), + ("ui.perspectivePresetsDialog", "Perspective Grid Presets…", "", "{selected?} open Edit › Perspective Grid Presets (dialog `perspectiveGridPresets`, field `selected`): New… and Edit… open the preset editor (dialog `perspectiveGrid` with `__mode` edit), whose OK runs perspective.presets.save and comes back; Delete, Import… and Export… run perspective.presets.*"), + ("ui.savePerspectivePreset", "Save Grid as Preset…", "", "{} open View › Perspective Grid › Save Grid as Preset (dialog `perspectiveGrid` with `__mode` save, `name` a new preset name): OK runs perspective.presets.save with the fields"), + ("ui.perspectiveUserPreset1.1", "One Point Perspective Preset 1", "", "{} apply the 1. saved 1-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset1.2", "One Point Perspective Preset 2", "", "{} apply the 2. saved 1-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset1.3", "One Point Perspective Preset 3", "", "{} apply the 3. saved 1-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset1.4", "One Point Perspective Preset 4", "", "{} apply the 4. saved 1-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset1.5", "One Point Perspective Preset 5", "", "{} apply the 5. saved 1-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset2.1", "Two Point Perspective Preset 1", "", "{} apply the 1. saved 2-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset2.2", "Two Point Perspective Preset 2", "", "{} apply the 2. saved 2-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset2.3", "Two Point Perspective Preset 3", "", "{} apply the 3. saved 2-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset2.4", "Two Point Perspective Preset 4", "", "{} apply the 4. saved 2-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset2.5", "Two Point Perspective Preset 5", "", "{} apply the 5. saved 2-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset3.1", "Three Point Perspective Preset 1", "", "{} apply the 1. saved 3-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset3.2", "Three Point Perspective Preset 2", "", "{} apply the 2. saved 3-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset3.3", "Three Point Perspective Preset 3", "", "{} apply the 3. saved 3-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset3.4", "Three Point Perspective Preset 4", "", "{} apply the 4. saved 3-point perspective grid preset (perspective.grid.preset)"), + ("ui.perspectiveUserPreset3.5", "Three Point Perspective Preset 5", "", "{} apply the 5. saved 3-point perspective grid preset (perspective.grid.preset)"), + ("ui.blendOptions", "Blend Options…", "", "{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with"), + ("ui.livePaintGapOptions", "Gap Options…", "", "{} open Gap Options (Object › Live Paint › Gap Options…; dialog `livePaintGapOptions`: detect, size small|medium|large|custom, custom (pt), color (#rrggbb or a layer color's name), closeGaps, preview) on the selected Live Paint groups, filled in from the first; with preview on, the options apply live and the gaps found draw on the canvas in the preview color: OK runs livePaint.gapOptions as one undo step; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing, and with preview on a #rrggbbaa color is not previewed: the groups keep their own color and the gaps draw in black"), + ("ui.perspectivePlane", "Perspective Plane Options…", "", "{plane?: left|right|ground (default: the active plane)} open the plane's options, as double-clicking its plane widget does (dialog `perspectivePlane`: location (pt along the plane's normal), objects: none|move|copy): OK runs perspective.plane.move"), +]; + diff --git a/docs/gaps.md b/docs/gaps.md index 19376c4bf2..07308c801a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -695,6 +695,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Estimate:** 4–8 h. - **Done (2026-10-16):** the MCP server hears the client during a long call: `notifications/cancelled` stops a queued or running export (PDF pages, flattening, baking, drawing, artboards) without writing anything or changing the document, `ping` and the list methods answer at once, any other request that would wait behind a call running for two seconds or more is refused as busy (`-32003`, naming the call), and `_meta.progressToken` gets page progress (#2165, #2171, #2172). Reading the document during an export is not served (refused as busy); estimate unchanged. - **Exposed in MCP:** `add_text {vertical: true}` uses the existing vertical point, area and path type commands. Horizontal writing remains the default; the broader automation gap and estimate are unchanged. +- **Done (#6460):** headless and remote `command_list` list desktop `UI_COMMANDS` (`ui: true`); UI-only runs still need `--connect`; effect ids are not command ids; 33 tools; eight prompts (spelling, variables, path-tools added); `vectorcraft://commands/reference` in the rebuilt CLI; prefer a checkout-built CLI when a release binary lags. Estimate unchanged. ## G15 Performance budgets and a GPU renderer @@ -852,6 +853,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | | 2026-11-11 | minor | G5: built-in Status Effects graphic style library (Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade, Shield Buff); Missing trimmed; estimate 7–17 → 6–16 h; MCP docs list UI Frames and Map Markers under Symbol libraries. | | 2026-11-11 | minor | G5: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem; no default 9-slice); Missing trimmed; estimate 8–18 → 7–17 h. | | 2026-11-10 | minor | G4 type: Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, #4936; the Check Spelling dialog remains; scores and estimates unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 7d66c7c0b3..28a04d4496 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -14,6 +14,17 @@ It has two backends: With no flag, the server tries `127.0.0.1:7979` and falls back to headless. Logs go to stderr; stdout carries only protocol messages. +### Command catalogue (headless vs remote) + +`command_list` / `list_commands` / `vectorcraft://commands` return the live catalogue for the backend in use: + +- **Headless:** engine and plug-in commands, plus the desktop menu catalogue (`UI_COMMANDS`) marked `"ui": true` so agents can discover ids such as `view.fitArtboard`, `file.newDialog` and `window.*`. Running a UI-only command that the headless host does not implement returns an error that points at `--connect`. +- **Remote:** the same lists, with each UI entry's `enabled` taken from the live app. Run UI commands with `command_run` / `run_command` or `invoke_menu`. + +Effect ids from `effect.list` / `apply_effect` / `vectorcraft://effect/{id}` (for example `warp.arc`, `blur.gaussian`, `threeD.revolve`, `adjust.hueSaturation`, `addon.path.dashes`, `addon.color.halftone`) are not command ids. Apply one with `effect.apply` or the `apply_effect` tool; do not pass an effect id to `command_run`. Path Effects keep the `addon.path.*` effect ids; the Path Tools add-on's *commands* are `addon.pathtools.*`. + +A release `vectorcraft-cli` on `PATH` can lag the checkout. Prefer `cargo install --path apps/vectorcraft-cli --locked` and put `$CARGO_HOME/bin` first in the MCP client's environment, or set the client's `command` to that binary's absolute path (#6460). + ## Install and register From the repository root, with Rust 1.95+ and the platform's source-build prerequisites installed: @@ -195,7 +206,7 @@ dropped; the call reports uncertainty like any reply that never arrived whole. ### Prompts -`prompts/list` offers five workflows; `prompts/get` returns one templated user message with its +`prompts/list` offers eight workflows; `prompts/get` returns one templated user message with its arguments filled in. A missing required argument is `-32602`. | Prompt | Arguments (required first) | @@ -205,6 +216,9 @@ arguments filled in. A missing required argument is `-32602`. | `recolor` | `palette`, `method` | | `trace-and-style` | `path`, `preset`, `effect` | | `export-set` | `formats`, `directory`, `scale` | +| `check-spelling` | `scope`, `action` | +| `variables-bind` | `kind`, `source` | +| `path-tools` | `task` | ```sh echo '{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}' \ @@ -374,11 +388,9 @@ The older names below remain listed because existing workflows use them. | Tool | Arguments and result | |---|---| -| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array. A command with a parameter schema lists `schema` and `inputSchema` (see [Parameter schemas](#parameter-schemas)). `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` | +| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array for the backend in use (see [Command catalogue](#command-catalogue-headless-vs-remote)). A command with a parameter schema lists `schema` and `inputSchema` (see [Parameter schemas](#parameter-schemas)). `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)). `run_command` runs a command the same way and takes its id as `command` | | `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath), and the edits made in a document that a step opens or creates (`document.open`, `file.new`) are one undo step there, named the same, also when no document was open before the batch. In a document that was open before the batch and that a step switches to (`document.activate`), each edit stays its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action | -| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array | -| `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) | | `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` | | `render_preview` | Optional `max_side` (1–4096, default 1024), `artboard` (0-based, default 0) and `traceViews`; inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). With `traceViews: true`, Image Trace objects draw in the view set with `imageTrace.setView` (outlines, the source image…), as in the app's window; by default they draw their tracing result. Artboards too large to render within the allocation bound return a tool error | | `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode. `ui_inspect` is the same call as `inspect_ui`, and `ui_screenshot` is `screenshot` with `window: true`. `ui_screenshot` takes an optional `max_side` (1–4096), as `screenshot {window: true}` does: a capture whose longer side is larger is scaled down to `max_side` pixels on that side, and the reply's text gives the returned size as `width`/`height` and the capture's as `capturedWidth`/`capturedHeight`. Without `max_side` the full capture comes back | @@ -464,7 +476,7 @@ opens the picker through `run_command` with the remote backend. | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc; `f`, `v`, `x` and `c` while dragging out a rectangular or polar grid). | | `type_text` | `{text}` | Remote only. | | `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | -| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | +| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | @@ -1419,8 +1431,9 @@ vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd ### Parameter schemas -Some commands have a machine-readable parameter schema: at present `view.capsLock`, `view.saved.new`, -`view.saved.edit`, `view.saved.list`, `view.transparencyGrid`, `view.snapToGlyph.options` and `engine.apiReference`. The engine's +Some commands have a machine-readable parameter schema: at present a small set including `view.capsLock`, `view.saved.new`, +`view.saved.edit`, `view.saved.list`, `view.transparencyGrid`, `view.snapToGlyph.options` and `engine.apiReference` +(most of the catalogue still documents params in prose only; schemas grow command by command). The engine's `engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12 (https://json-schema.org/draft/2020-12). Its root has `$schema` (the 2020-12 dialect), `description` when the command has a summary, and `deprecated: true` when the command is deprecated. The schema uses `type`, `properties`, `required` and @@ -4570,7 +4583,7 @@ draws, and View › Snap to Grid snaps to its subdivisions. Changing either sets every document they read except a native file and the native document that an SVG, PDF, `.ai` or EPS file carries (`restored: true`), which keep the grid they were saved with. -`view.rotate {angle}` (desktop app) turns the view to `angle` degrees about the center of the canvas; a positive angle +`view.rotate {angle}` (desktop app, `ui: true`) turns the view to `angle` degrees about the center of the canvas; a positive angle turns the art clockwise on screen. View › Rotate View and the status bar's angle menu run it. It returns `{angle}`, the angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0, returning `null` on success. @@ -4578,7 +4591,9 @@ Select the Rotate View Tool with `select_tool` and the tool id `rotateView`. Its drags turn the canvas about its center; Shift snaps to 15°. Document coordinates for a rotation gesture use the view at the initial down event, including when the gesture spans several calls. The control channel rejects malformed rotation packets without changing the angle and cancels -the current press. Start a new down event after an error. +the current press. Start a new down event after an error. Menu commands that only the desktop view +implements (`view.rotate`, `view.rotateReset`, `view.rotateToSelection`) are listed with `ui: true` +and need `--connect` when the headless host does not run them. Reset Rotate View is the command `view.rotateReset`: ```json @@ -4908,7 +4923,10 @@ settings and the next untitled name, without opening the dialog, and returns wha open document in tab order the current one, as a click on its tab does, and return `{index}`. After the last document comes the first, and before the first document comes the last. From the Home screen they go to the first and the last document. With no document open they fail with "no document open". `document.activate {index}` makes a given document -the current one. +the current one. `document.move {from?, to}` reorders open documents the way dragging a document tab along the tab strip +does: `from` defaults to the current document, `to` is the destination index, documents between them shift by one, the +current document stays current, and each document keeps its own view (#5663). It is an engine command, so headless and +remote `command_list` both list it. `window.arrange.tile` (Window › Arrange › Tile) shows every open document in its own pane, edge to edge in the document area, in a grid of as many columns as the square root of the number of documents, rounded up, and fits each document to its pane; with fewer than two documents open it leaves the view and `tiled` as they are. It returns `{tiled, panes}`. @@ -8460,10 +8478,11 @@ plain path from then on. ### Path Effects (live) Effect › Path Effects holds the add-on's live geometry effects, applied with `effect.apply {effect, params}` like any -other effect, with no shortcuts. Each draws, measures, exports and stacks as the other geometry effects do; the object's -own path stays as it is until Expand Appearance (`effect.expandAppearance`) writes the result. `effect.list` documents -every parameter. Out-of-range values are clamped, and an effect whose result would pass the geometry effects' budget -(2 million anchors) leaves the path as it is. +other effect, with no shortcuts. The ids below (`addon.path.*`) are **effect** ids, not command ids; the Path Tools +add-on's commands are `addon.pathtools.*` (see above). Each effect draws, measures, exports and stacks as the other +geometry effects do; the object's own path stays as it is until Expand Appearance (`effect.expandAppearance`) writes +the result. `effect.list` documents every parameter. Out-of-range values are clamped, and an effect whose result would +pass the geometry effects' budget (2 million anchors) leaves the path as it is. | Id | Menu item | Parameters | | --- | --- | --- | diff --git a/docs/roadmap.md b/docs/roadmap.md index 82362eb94c..241e786f95 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -52,6 +52,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-10 | minor | M13 / G6: MCP catalogue (#6460): headless lists UI commands, eight prompts, 33 tools, effect vs command ids; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 07d626e5a4..f8d2b557f8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -741,7 +741,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). + - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. @@ -1973,6 +1973,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | | 2026-11-11 | minor | Bundled content: built-in Status Effects graphic style library; style libraries evidence updated; ~40% → ~42%; MCP Symbol libraries list UI Frames and Map Markers; more built-in graphic-style libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem); symbol libraries evidence updated; ~38% → ~40%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Type advanced: the Edit Custom Dictionary dialog, #4936; evidence added; the Check Spelling dialog remains; scores and hours unchanged. | From 3f26409d4ac7d420f25a23aa573d9a8490080d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Wed, 11 Nov 2026 15:56:33 +0000 Subject: [PATCH 24/49] MCP: keep UI_COMMANDS mirror in sync via xtask Add `cargo xtask ui-commands` (and `--check` in ci) so the headless catalogue cannot drift from desktop UI_COMMANDS, and fix params-doc escaping that printed literal backslashes. --- crates/mcp/src/headless.rs | 3 +- crates/mcp/src/prompts.rs | 38 +- crates/mcp/src/tests.rs | 5 +- crates/mcp/src/ui_catalog.rs | 1711 +++++++++++++++++++++++++++++----- docs/mcp.md | 1 + xtask/src/main.rs | 6 +- xtask/src/ui_commands.rs | 466 +++++++++ 7 files changed, 1954 insertions(+), 276 deletions(-) create mode 100644 xtask/src/ui_commands.rs diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 4d4a746dd5..00e914ddba 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -153,8 +153,7 @@ impl Headless { fn commands(&self) -> Value { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); - let mut seen: std::collections::HashSet = - v.iter().filter_map(|c| c.get("id").and_then(Value::as_str).map(str::to_owned)).collect(); + let mut seen: std::collections::HashSet = v.iter().filter_map(|c| c.get("id").and_then(Value::as_str).map(str::to_owned)).collect(); for (id, label, params) in HOST_COMMANDS { if !seen.insert((*id).to_owned()) { continue; diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 95363fde00..7741d9228f 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -424,11 +424,7 @@ Desktop-only Auto Spell Check is edit.autoSpellCheck (ui: true); use --connect f description: "Where the data comes from", required: false, default: "define variables on the selection by hand", - values: Values::Fixed(&[ - "define variables on the selection by hand", - "import a CSV or TSV data set", - "load a variable library XML", - ]), + values: Values::Fixed(&["define variables on the selection by hand", "import a CSV or TSV data set", "load a variable library XML"]), }, ], text: "\ @@ -446,23 +442,21 @@ Method: name: "path-tools", title: "Path Tools add-on", description: "Use addon.pathtools.* commands (and addon.path.* live effects) to edit path geometry.", - arguments: &[ - PromptArg { - name: "task", - description: "What to do to the selection", - required: true, - default: "", - values: Values::Fixed(&[ - "extend open ends", - "close paths", - "add anchors along curves", - "simplify anchors", - "split at anchors", - "expand dashes to geometry", - "create a parametric shape", - ]), - }, - ], + arguments: &[PromptArg { + name: "task", + description: "What to do to the selection", + required: true, + default: "", + values: Values::Fixed(&[ + "extend open ends", + "close paths", + "add anchors along curves", + "simplify anchors", + "split at anchors", + "expand dashes to geometry", + "create a parametric shape", + ]), + }], text: "\ Perform this Path Tools task in VectorCraft: {task}. diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 16e15bed35..d7de8dd7fb 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -250,10 +250,7 @@ fn headless_command_list_includes_post_022_engine_commands() { let r = call(&mut s, 10 + n as u64, "command_list", json!({"filter": id, "brief": true})); assert_eq!(r["isError"], false, "{id}: {r}"); let list: Value = serde_json::from_str(&text_of(&r)).unwrap(); - assert!( - list.as_array().unwrap().iter().any(|c| c["id"] == *id), - "missing {id} in {list}" - ); + assert!(list.as_array().unwrap().iter().any(|c| c["id"] == *id), "missing {id} in {list}"); } } diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index c877045c28..e15244580d 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1,16 +1,52 @@ //! Desktop UI command catalogue for headless MCP discovery (`ui: true`). -//! Generated from `crates/ui-egui/src/menus/ui_commands.rs`. Re-run the extractor when that table changes. +//! Generated from `crates/ui-egui/src/menus/ui_commands.rs` by `cargo xtask ui-commands`. +//! Do not edit by hand; re-run the xtask when that table changes. /// `(id, label, shortcut, params doc)` — same shape as the desktop `UI_COMMANDS` table. pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("addon.pathtools.cornerTool", "Corner Tool", "", "{} choose the Corner Tool and show the Anchor Inspector panel with its radius and kind"), - ("addon.pathtools.sharedEdgeTool", "Shared Edge Merge Tool", "", "{} choose the Shared Edge Merge Tool and show the Anchor Inspector panel with its brush, coincidence and Keep Shared Points Together"), - ("addon.arrange.zoomSelection", "Zoom to Selection", "", "{} zoom and centre the active view so the selection's visual bounds fill the window, with a margin (as view.fitArtboard fits the artboard); off with nothing selected"), - ("app.language", "Interface Language", "", "{lang: auto|} the interface language, persisted as the `interfaceLanguage` preference (`auto` follows the system locale; codes: prefs.list › interfaceLanguage, e.g. en, ja, cs, es, zh-hant)"), - ("file.open", "Open…", "Cmd+O", "{path?, params?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads); `params` are the parameters of the importer plug-in that opens the file"), - ("file.openWith", "Open With", "", "{importer: \\\"plugin.\\\" (an importer plug-in's format id, see document.formats), path?} File › Open With: open the file with that importer (document.open's importer), also a file whose extension a built-in format reads; without a path pick one of the files with the extensions it lists (the web opens File › Open's picker, and the file opens as File › Open opens it) → with a path, document.open's result; a picked file is read off the UI thread on the desktop, {background: true, status}, and opens once the importer returns (file.cancelOpen stops it) | null while the picker is open"), - ("file.save", "Save", "Cmd+S", "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first"), - ("file.place", "Place…", "Cmd+Shift+P", "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)"), + ( + "addon.pathtools.sharedEdgeTool", + "Shared Edge Merge Tool", + "", + "{} choose the Shared Edge Merge Tool and show the Anchor Inspector panel with its brush, coincidence and Keep Shared Points Together", + ), + ( + "addon.arrange.zoomSelection", + "Zoom to Selection", + "", + "{} zoom and centre the active view so the selection's visual bounds fill the window, with a margin (as view.fitArtboard fits the artboard); off with nothing selected", + ), + ( + "app.language", + "Interface Language", + "", + "{lang: auto|} the interface language, persisted as the `interfaceLanguage` preference (`auto` follows the system locale; codes: prefs.list › interfaceLanguage, e.g. en, ja, cs, es, zh-hant)", + ), + ( + "file.open", + "Open…", + "Cmd+O", + "{path?, params?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads); `params` are the parameters of the importer plug-in that opens the file", + ), + ( + "file.openWith", + "Open With", + "", + "{importer: \"plugin.\" (an importer plug-in's format id, see document.formats), path?} File › Open With: open the file with that importer (document.open's importer), also a file whose extension a built-in format reads; without a path pick one of the files with the extensions it lists (the web opens File › Open's picker, and the file opens as File › Open opens it) → with a path, document.open's result; a picked file is read off the UI thread on the desktop, {background: true, status}, and opens once the importer returns (file.cancelOpen stops it) | null while the picker is open", + ), + ( + "file.save", + "Save", + "Cmd+S", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + ), + ( + "file.place", + "Place…", + "Cmd+Shift+P", + "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)", + ), ("file.openRecent1", "Open Recent File 1", "", "{}"), ("file.openRecent2", "Open Recent File 2", "", "{}"), ("file.openRecent3", "Open Recent File 3", "", "{}"), @@ -88,89 +124,324 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("type.recentFont15", "Recent Font 15", "", "{} apply the 15. most recently used font"), ("file.clearRecent", "Clear Recent Files", "", "{}"), ("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"), - ("ui.missingFontsDialog", "Missing Fonts Dialog", "", "{folder?} open Missing Fonts (dialog `missingFonts`) for the fonts the active document uses that aren't available (text.missingFonts); with folder, it looks for their files there at once (text.findFontFiles). An error when no font is missing. Opening a document whose fonts are missing shows it after the missing linked file questions (dialog `missingLinks`). Disabled on the web"), - ("ui.findFontsInFolder", "Find Fonts in Folder…", "", "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)"), - ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), - ("file.export.svg", "Export As SVG…", "", "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)"), + ( + "ui.missingFontsDialog", + "Missing Fonts Dialog", + "", + "{folder?} open Missing Fonts (dialog `missingFonts`) for the fonts the active document uses that aren't available (text.missingFonts); with folder, it looks for their files there at once (text.findFontFiles). An error when no font is missing. Opening a document whose fonts are missing shows it after the missing linked file questions (dialog `missingLinks`). Disabled on the web", + ), + ( + "ui.findFontsInFolder", + "Find Fonts in Folder…", + "", + "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)", + ), + ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first; under an automation read root, only the files inside it"), + ( + "file.export.svg", + "Export As SVG…", + "", + "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)", + ), ("file.export.png", "Export As PNG…", "", "{path?, …document.export options}; no path: pick the file, then PNG Options"), - ("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager; a folder on the desktop with Background Export on: the files are written in the background → {background: true, status})"), + ( + "file.exportForScreens", + "Export for Screens…", + "Cmd+Alt+E", + "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager; a folder on the desktop with Background Export on: the files are written in the background → {background: true, status})", + ), ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), - ("file.newWithoutDialog", "New Without Dialog", "Cmd+Alt+N", "{} makes a document with the settings the New Document dialog opens on (the first Recent entry, else the Print category's Letter), named after the next untitled document, without opening the dialog → {index, previewMode}"), + ( + "file.newWithoutDialog", + "New Without Dialog", + "Cmd+Alt+N", + "{} makes a document with the settings the New Document dialog opens on (the first Recent entry, else the Print category's Letter), named after the next untitled document, without opening the dialog → {index, previewMode}", + ), ("app.home", "Home", "", "{} shows the Home screen (new file presets, Open) over the open documents; choosing a document tab returns to it"), ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), ("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"), - ("edit.autoSpellCheck", "Auto Spell Check", "", "{} toggle a red underline under the words of the document's type its dictionaries don't know (text.spelling.check lists them), kept up to date as the type changes; type with effects isn't underlined → whether Auto Spell Check is on"), - ("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:, shortcut: \\\"Cmd+Shift+K\\\" | \\\"\\\" (none) | null (default), force?}"), + ( + "edit.autoSpellCheck", + "Auto Spell Check", + "", + "{} toggle a red underline under the words of the document's type its dictionaries don't know (text.spelling.check lists them), kept up to date as the type changes; type with effects isn't underlined → whether Auto Spell Check is on", + ), + ("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:, shortcut: \"Cmd+Shift+K\" | \"\" (none) | null (default), force?}"), ("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"), ("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"), ("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"), - ("shortcuts.preset", "Keyboard Shortcut Set", "", "{name: \\\"VectorCraft Defaults\\\" | \\\"Classic Defaults\\\"} (names of earlier versions are accepted)"), + ( + "shortcuts.preset", + "Keyboard Shortcut Set", + "", + "{name: \"VectorCraft Defaults\" | \"Classic Defaults\"} (names of earlier versions are accepted)", + ), ("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"), - ("shortcuts.exportText", "Export Keyboard Shortcuts as Text…", "", "{path?} save the current set's tools and menu commands with their shortcuts as a UTF-8 text file (Keyboard Shortcuts › Export Text…): the set's name, then a Tools and a Menu Commands section in the dialog's order, one `nameshortcut` line per row, empty after the tab when it has none; without `path`, pick a file"), + ( + "shortcuts.exportText", + "Export Keyboard Shortcuts as Text…", + "", + "{path?} save the current set's tools and menu commands with their shortcuts as a UTF-8 text file (Keyboard Shortcuts › Export Text…): the set's name, then a Tools and a Menu Commands section in the dialog's order, one `nameshortcut` line per row, empty after the tab when it has none; without `path`, pick a file", + ), ("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"), - ("ui.shortcutImportDialog", "Import Shortcut Set…", "", "{path? | dataBase64?, name?} open Import Shortcut Set for a shortcut set another app saved as text (.kys; without a file, pick one): each entry matched to a tool, command or panel, and its key and modifier codes decoded (dialog `shortcutImport`: name, start: defaults|current, remember, rows: [{name, section, line, key, modifiers, target: entry id | \\\"\\\" (no equivalent) | null (not chosen), from, choices, on}], keys and mods: {code: {text, from, votes, names}}); Apply assigns the ready rows over Edit › Keyboard Shortcuts' working set, remembers the matches (importMatches.set) and shows Keyboard Shortcuts, whose OK keeps them. File › Open of a .kys opens it too"), + ( + "ui.shortcutImportDialog", + "Import Shortcut Set…", + "", + "{path? | dataBase64?, name?} open Import Shortcut Set for a shortcut set another app saved as text (.kys; without a file, pick one): each entry matched to a tool, command or panel, and its key and modifier codes decoded (dialog `shortcutImport`: name, start: defaults|current, remember, rows: [{name, section, line, key, modifiers, target: entry id | \"\" (no equivalent) | null (not chosen), from, choices, on}], keys and mods: {code: {text, from, votes, names}}); Apply assigns the ready rows over Edit › Keyboard Shortcuts' working set, remembers the matches (importMatches.set) and shows Keyboard Shortcuts, whose OK keeps them. File › Open of a .kys opens it too", + ), ("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"), ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), - ("view.snapToGlyph", "Snap to Glyph", "", "{} toggle: drawing snaps to live-text glyph bounds, and on a single line of type to its baseline, x-height, character widths and glyph anchor points"), - ("view.preciseCursors", "Use Precise Cursors", "", "{} toggle General › Use Precise Cursors: the drawing tools' pointers become a crosshair → true when on"), - ("imageTrace.preview", "Image Trace Preview", "", "{on?: bool} turn the Image Trace panel's Preview on (true), off (false) or toggle it (omitted), as its checkbox does: on, setting changes trace the selected Image Trace object at once; off, they wait for Trace; returns the new state"), + ( + "view.snapToGlyph", + "Snap to Glyph", + "", + "{} toggle: drawing snaps to live-text glyph bounds, and on a single line of type to its baseline, x-height, character widths and glyph anchor points", + ), + ( + "view.preciseCursors", + "Use Precise Cursors", + "", + "{} toggle General › Use Precise Cursors: the drawing tools' pointers become a crosshair → true when on", + ), + ( + "imageTrace.preview", + "Image Trace Preview", + "", + "{on?: bool} turn the Image Trace panel's Preview on (true), off (false) or toggle it (omitted), as its checkbox does: on, setting changes trace the selected Image Trace object at once; off, they wait for Trace; returns the new state", + ), ("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"), ("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"), - ("view.livePaintGaps", "Show Live Paint Gaps", "", "{} toggle drawing the gaps of the selected Live Paint groups (those livePaint.info lists) in each group's Gap Preview Color while edges show → true when on"), + ( + "view.livePaintGaps", + "Show Live Paint Gaps", + "", + "{} toggle drawing the gaps of the selected Live Paint groups (those livePaint.info lists) in each group's Gap Preview Color while edges show → true when on", + ), ("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"), ("type.alignLeft", "Align left", "Cmd+Shift+L", "{} align the selected type's paragraphs (or those the Type tool's selection touches) left"), - ("type.alignCenter", "Align center", "Cmd+Shift+C", "{} align the selected type's paragraphs (or those the Type tool's selection touches) centred"), + ( + "type.alignCenter", + "Align center", + "Cmd+Shift+C", + "{} align the selected type's paragraphs (or those the Type tool's selection touches) centred", + ), ("type.alignRight", "Align right", "Cmd+Shift+R", "{} align the selected type's paragraphs (or those the Type tool's selection touches) right"), ("type.justifyLeft", "Justify with last line aligned left", "Cmd+Shift+J", "{} justify the selected type's paragraphs, last line aligned left"), ("type.justifyAll", "Justify all lines", "Cmd+Shift+F", "{} justify every line of the selected type's paragraphs"), - ("type.applyBullets", "Apply Bullets", "", "{bullet?: \\\"dot\\\"|\\\"dash\\\"|\\\"circle\\\"|\\\"square\\\"|\\\"none\\\"} Type › Bullets and Numbering › Apply Bullets: the selected type's paragraphs (or those the Type tool's selection touches) become a bulleted list with that bullet (text.setFormat {bullet}); without it, a list keeps its bullet and other paragraphs take the dot (text.setFormat {list: \\\"bullet\\\"}); \\\"none\\\" turns the list off"), - ("type.applyNumbers", "Apply Numbers", "", "{number?: \\\"arabic\\\"|\\\"upperLetters\\\"|\\\"lowerLetters\\\"|\\\"upperRoman\\\"|\\\"lowerRoman\\\"|\\\"twoDigits\\\"|\\\"threeDigits\\\"|\\\"none\\\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \\\"number\\\"}); \\\"none\\\" turns the list off"), - ("ui.bulletsAndNumbering", "Bullets and Numbering…", "", "{tab?: \\\"bullets\\\"|\\\"numbers\\\"} open Bullets and Numbering (More Options in the Paragraph panel's Bullets and Numbered List presets) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, on that tab (without it, Numbers when the paragraphs are a numbered list, else Bullets) (dialog `bulletsAndNumbering`: tab (bullets|numbers), list (none|bullet|number: the kind of the preset picked), bullet (dot|dash|circle|square), number (arabic|upperLetters|lowerLetters|upperRoman|lowerRoman|twoDigits|threeDigits), listAlign (left|right), listLevel (1..9; 1 for paragraphs in no list), leftIndent, firstLineIndent (pt)): the type shows the dialog's values while it is open, OK runs text.setFormat with the values that changed (and the indents it shows when a preset puts the paragraphs in a list), as one undo step, and Cancel puts the type back; a preset picked for paragraphs in no list whose indents are 0 fills in 18 and -18 pt; an error with no type selected or an unknown tab"), - ("type.convertListToText", "Convert to Text", "", "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type"), - ("align.horizontalLeft", "Horizontal Align Left", "", "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \\\"left\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), - ("align.horizontalCenter", "Horizontal Align Center", "", "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \\\"center\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), - ("align.horizontalRight", "Horizontal Align Right", "", "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \\\"right\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), - ("align.horizontalVerticalCenter", "Horizontal & Vertical Align Center", "", "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \\\"center\\\", vertical: \\\"center\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), - ("align.verticalTop", "Vertical Align Top", "", "{} the Align panel's Vertical Align Top button: object.align {vertical: \\\"top\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), - ("align.verticalCenter", "Vertical Align Center", "", "{} the Align panel's Vertical Align Center button: object.align {vertical: \\\"center\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), - ("align.verticalBottom", "Vertical Align Bottom", "", "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \\\"bottom\\\"} with to: \\\"key\\\" while there is a key, else \\\"artboard\\\" for one selected object without selected anchor points, else the Align To choice"), - ("align.distributeVerticalTop", "Vertical Distribute Top", "", "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \\\"top\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), - ("align.distributeVerticalCenter", "Vertical Distribute Center", "", "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \\\"center\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), - ("align.distributeVerticalBottom", "Vertical Distribute Bottom", "", "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \\\"bottom\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), - ("align.distributeHorizontalLeft", "Horizontal Distribute Left", "", "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \\\"left\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), - ("align.distributeHorizontalCenter", "Horizontal Distribute Center", "", "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \\\"center\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), - ("align.distributeHorizontalRight", "Horizontal Distribute Right", "", "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \\\"right\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), - ("align.distributeVerticalSpace", "Vertical Distribute Space", "", "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \\\"vertical\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), - ("align.distributeHorizontalSpace", "Horizontal Distribute Space", "", "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \\\"horizontal\\\"} with to: \\\"key\\\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key"), - ("type.bold", "Bold", "", "{} the selected type (or the Type tool's selection) in its family's Bold face, or back to the regular face when it is bold; keeps italics. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+B"), - ("type.italic", "Italic", "", "{} the selected type (or the Type tool's selection) in its family's Italic (or Oblique) face, or back upright when it is italic; keeps the weight. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+I"), - ("type.highlightFontName", "Highlight Font Name", "Cmd+Alt+Shift+F", "{} show the Character panel if it is hidden and open its font family menu, whose search field takes the keyboard focus: type to search, Enter applies the highlighted font → {field: \\\"fontName\\\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first"), - ("type.highlightKerning", "Highlight Kerning", "Cmd+Alt+K", "{} show the Character panel if it is hidden and give its Kerning field the keyboard focus with its value selected: type a value and press Enter → {field: \\\"kerning\\\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first"), - ("type.highlightTracking", "Highlight Tracking", "", "{} show the Character panel if it is hidden and give its Tracking field the keyboard focus with its value selected: type a value and press Enter → {field: \\\"tracking\\\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first"), - ("type.underline", "Underline", "Cmd+Shift+U", "{} the Character panel's Underline button: underline the selected type (or the Type tool's selection), or remove the underline when the text the Character panel shows is underlined (text.setFormat {underline}) → underlined"), - ("type.lineComposer", "Toggle Line Composer", "Cmd+Alt+Shift+C", "{} the selected type's paragraphs (or those the Type tool's selection touches) use the Single-line Composer when they all use the Every-line Composer, else the Every-line Composer (text.setFormat {composer}) → \\\"singleLine\\\" | \\\"everyLine\\\""), - ("type.hyphenate", "Hyphenate", "Cmd+Alt+Shift+H", "{} the Paragraph panel's Hyphenate checkbox: hyphenation off for the selected type's paragraphs (or those the Type tool's selection touches) when they all hyphenate, else on (text.setFormat {hyphenate}) → hyphenate"), + ( + "type.applyBullets", + "Apply Bullets", + "", + "{bullet?: \"dot\"|\"dash\"|\"circle\"|\"square\"|\"none\"} Type › Bullets and Numbering › Apply Bullets: the selected type's paragraphs (or those the Type tool's selection touches) become a bulleted list with that bullet (text.setFormat {bullet}); without it, a list keeps its bullet and other paragraphs take the dot (text.setFormat {list: \"bullet\"}); \"none\" turns the list off", + ), + ( + "type.applyNumbers", + "Apply Numbers", + "", + "{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off", + ), + ( + "ui.bulletsAndNumbering", + "Bullets and Numbering…", + "", + "{tab?: \"bullets\"|\"numbers\"} open Bullets and Numbering (More Options in the Paragraph panel's Bullets and Numbered List presets) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, on that tab (without it, Numbers when the paragraphs are a numbered list, else Bullets) (dialog `bulletsAndNumbering`: tab (bullets|numbers), list (none|bullet|number: the kind of the preset picked), bullet (dot|dash|circle|square), number (arabic|upperLetters|lowerLetters|upperRoman|lowerRoman|twoDigits|threeDigits), listAlign (left|right), listLevel (1..9; 1 for paragraphs in no list), leftIndent, firstLineIndent (pt)): the type shows the dialog's values while it is open, OK runs text.setFormat with the values that changed (and the indents it shows when a preset puts the paragraphs in a list), as one undo step, and Cancel puts the type back; a preset picked for paragraphs in no list whose indents are 0 fills in 18 and -18 pt; an error with no type selected or an unknown tab", + ), + ( + "type.convertListToText", + "Convert to Text", + "", + "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", + ), + ( + "align.horizontalLeft", + "Horizontal Align Left", + "", + "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalCenter", + "Horizontal Align Center", + "", + "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalRight", + "Horizontal Align Right", + "", + "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalVerticalCenter", + "Horizontal & Vertical Align Center", + "", + "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalTop", + "Vertical Align Top", + "", + "{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalCenter", + "Vertical Align Center", + "", + "{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalBottom", + "Vertical Align Bottom", + "", + "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.distributeVerticalTop", + "Vertical Distribute Top", + "", + "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalCenter", + "Vertical Distribute Center", + "", + "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalBottom", + "Vertical Distribute Bottom", + "", + "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalLeft", + "Horizontal Distribute Left", + "", + "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalCenter", + "Horizontal Distribute Center", + "", + "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalRight", + "Horizontal Distribute Right", + "", + "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalSpace", + "Vertical Distribute Space", + "", + "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalSpace", + "Horizontal Distribute Space", + "", + "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "type.bold", + "Bold", + "", + "{} the selected type (or the Type tool's selection) in its family's Bold face, or back to the regular face when it is bold; keeps italics. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+B", + ), + ( + "type.italic", + "Italic", + "", + "{} the selected type (or the Type tool's selection) in its family's Italic (or Oblique) face, or back upright when it is italic; keeps the weight. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+I", + ), + ( + "type.highlightFontName", + "Highlight Font Name", + "Cmd+Alt+Shift+F", + "{} show the Character panel if it is hidden and open its font family menu, whose search field takes the keyboard focus: type to search, Enter applies the highlighted font → {field: \"fontName\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first", + ), + ( + "type.highlightKerning", + "Highlight Kerning", + "Cmd+Alt+K", + "{} show the Character panel if it is hidden and give its Kerning field the keyboard focus with its value selected: type a value and press Enter → {field: \"kerning\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first", + ), + ( + "type.highlightTracking", + "Highlight Tracking", + "", + "{} show the Character panel if it is hidden and give its Tracking field the keyboard focus with its value selected: type a value and press Enter → {field: \"tracking\"}. Without selected type (or text the Type tool edits) the panel shows, and the error says to select type first", + ), + ( + "type.underline", + "Underline", + "Cmd+Shift+U", + "{} the Character panel's Underline button: underline the selected type (or the Type tool's selection), or remove the underline when the text the Character panel shows is underlined (text.setFormat {underline}) → underlined", + ), + ( + "type.lineComposer", + "Toggle Line Composer", + "Cmd+Alt+Shift+C", + "{} the selected type's paragraphs (or those the Type tool's selection touches) use the Single-line Composer when they all use the Every-line Composer, else the Every-line Composer (text.setFormat {composer}) → \"singleLine\" | \"everyLine\"", + ), + ( + "type.hyphenate", + "Hyphenate", + "Cmd+Alt+Shift+H", + "{} the Paragraph panel's Hyphenate checkbox: hyphenation off for the selected type's paragraphs (or those the Type tool's selection touches) when they all hyphenate, else on (text.setFormat {hyphenate}) → hyphenate", + ), ("effect.last", "Last Effect…", "Cmd+Alt+Shift+E", "{} open the dialog of the last effect applied"), ("view.zoomIn", "Zoom In", "Cmd+=", "{}"), ("view.zoomOut", "Zoom Out", "Cmd+-", "{}"), ("view.fitArtboard", "Fit Artboard in Window", "Cmd+0", "{}"), ("view.fitAll", "Fit All in Window", "Cmd+Alt+0", "{}"), - ("view.actualSize", "Actual Size", "Cmd+1", "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)"), + ( + "view.actualSize", + "Actual Size", + "Cmd+1", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + ), ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), - ("view.goToArtboard", "Go to Artboard", "", "{index: 0-based number | \\\"first\\\" | \\\"previous\\\" | \\\"next\\\" | \\\"last\\\"} make that artboard the status bar navigator's (the one Fit Artboard in Window and Actual Size show) and fit it in the window → {index}"), - ("view.firstArtboard", "First Artboard", "", "{} the status bar navigator's First Artboard button: view.goToArtboard {index: \\\"first\\\"} → {index}"), - ("view.previousArtboard", "Previous Artboard", "", "{} the navigator's Previous Artboard button: view.goToArtboard {index: \\\"previous\\\"} → {index}"), - ("view.nextArtboard", "Next Artboard", "", "{} the navigator's Next Artboard button: view.goToArtboard {index: \\\"next\\\"} → {index}"), - ("view.lastArtboard", "Last Artboard", "", "{} the navigator's Last Artboard button: view.goToArtboard {index: \\\"last\\\"} → {index}"), + ( + "view.goToArtboard", + "Go to Artboard", + "", + "{index: 0-based number | \"first\" | \"previous\" | \"next\" | \"last\"} make that artboard the status bar navigator's (the one Fit Artboard in Window and Actual Size show) and fit it in the window → {index}", + ), + ( + "view.firstArtboard", + "First Artboard", + "", + "{} the status bar navigator's First Artboard button: view.goToArtboard {index: \"first\"} → {index}", + ), + ( + "view.previousArtboard", + "Previous Artboard", + "", + "{} the navigator's Previous Artboard button: view.goToArtboard {index: \"previous\"} → {index}", + ), + ("view.nextArtboard", "Next Artboard", "", "{} the navigator's Next Artboard button: view.goToArtboard {index: \"next\"} → {index}"), + ("view.lastArtboard", "Last Artboard", "", "{} the navigator's Last Artboard button: view.goToArtboard {index: \"last\"} → {index}"), ("view.edges", "Hide Edges", "Cmd+H", "{}"), ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), - ("view.template", "Hide Template", "Cmd+Shift+W", "{} template layers left off the canvas (Show Template draws them again, dimmed as before) → shown"), + ( + "view.template", + "Hide Template", + "Cmd+Shift+W", + "{} template layers left off the canvas (Show Template draws them again, dimmed as before) → shown", + ), ("view.rulers", "Show Rulers", "Cmd+R", "{}"), - ("view.videoRulers", "Show Video Rulers", "", "{} the rulers show (as Show Rulers shows them) and count the active artboard in video pixels from its top-left corner, a pixel as wide as its Video Ruler Pixel Aspect Ratio says (artboard.setProps videoRulerPixelAspectRatio) and one point tall; again (Hide Video Rulers), they count in the General unit from the ruler origin as before → whether video rulers show"), + ( + "view.videoRulers", + "Show Video Rulers", + "", + "{} the rulers show (as Show Rulers shows them) and count the active artboard in video pixels from its top-left corner, a pixel as wide as its Video Ruler Pixel Aspect Ratio says (artboard.setProps videoRulerPixelAspectRatio) and one point tall; again (Hide Video Rulers), they count in the General unit from the ruler origin as before → whether video rulers show", + ), ("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"), ("view.guides", "Hide Guides", "Cmd+;", "{}"), ("view.smartGuides", "Smart Guides", "Cmd+U", "{}"), @@ -179,96 +450,431 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.snapToPoint", "Snap to Point", "Cmd+Alt+'", "{}"), ("view.presentation", "Presentation Mode", "Shift+F", "{}"), ("view.screenMode", "Screen Mode", "F", "{mode?: 0..2} (no param cycles)"), - ("view.rotate", "Rotate View", "", "{angle: degrees, clockwise on screen as the status bar shows it} turn the view to that angle about the center of the canvas; returns {angle}, normalized to (-180, 180]"), - ("view.rotateToSelection", "Rotate View to Selection", "", "{} turn the view about the center of the canvas to the angle the selected objects share (selectionRotation in document.inspect, 0 when they differ), so their bounding box is square to the window; returns {angle}"), + ( + "view.rotate", + "Rotate View", + "", + "{angle: degrees, clockwise on screen as the status bar shows it} turn the view to that angle about the center of the canvas; returns {angle}, normalized to (-180, 180]", + ), + ( + "view.rotateToSelection", + "Rotate View to Selection", + "", + "{} turn the view about the center of the canvas to the angle the selected objects share (selectionRotation in document.inspect, 0 when they differ), so their bounding box is square to the window; returns {angle}", + ), ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), ("window.control", "Control", "", "{}"), ("window.helpBar", "Help Bar", "", "{} toggle the tool-help strip independently of the status bar; returns the new visibility"), - ("window.control.dock", "Dock Control Bar", "", "{edge: \\\"top\\\"|\\\"bottom\\\"} dock the Control bar to the top of the window, under the menu bar, or to its bottom, as the bar's panel menu (Dock to Top, Dock to Bottom) and a drag of its gripper do; kept with the workspace; another edge is an error and changes nothing; returns {edge}"), + ( + "window.control.dock", + "Dock Control Bar", + "", + "{edge: \"top\"|\"bottom\"} dock the Control bar to the top of the window, under the menu bar, or to its bottom, as the bar's panel menu (Dock to Top, Dock to Bottom) and a drag of its gripper do; kept with the workspace; another edge is an error and changes nothing; returns {edge}", + ), ("window.toolbar", "Tools", "", "{}"), - ("window.toolbarColumns", "Toolbar: Single/Double Column", "", "{double?: bool, toolbar?: name} show the toolbar's tools (with `toolbar`, that toolbar made with New Toolbar's) in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state"), + ( + "window.toolbarColumns", + "Toolbar: Single/Double Column", + "", + "{double?: bool, toolbar?: name} show the toolbar's tools (with `toolbar`, that toolbar made with New Toolbar's) in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state", + ), ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), - ("window.toolsBesideCanvas", "Keep Tools Beside the Canvas", "", "{on?: bool} dock the Tools panel between the left-side panels and the canvas (true) or at the window's left edge (false, the default), or toggle (omitted or null; any other value is an error); a floating Tools panel stays floating and docks at the chosen place when it is docked again. Kept with the workspace; a workspace saved without it docks Tools at the window's edge. Returns the new state"), - ("window.floatTools", "Float Tool Group", "", "{tool: id, floating?: bool, toolbar?: name} float the toolbar group holding `tool` (in the current Basic or Advanced layout; with `toolbar`, in that toolbar made with New Toolbar) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state"), - ("toolbar.show", "Toolbar: Show Controls", "", "{control?: \\\"fillStroke\\\"|\\\"coloring\\\"|\\\"drawMode\\\"|\\\"screenMode\\\", on?: bool} show (true), hide (false) or toggle (omitted) one of the controls under the toolbar's tools (Fill and Stroke, Coloring, Draw Mode, Screen Mode), as the All Tools drawer's Show section does; saved with the preferences; returns every control's state"), - ("window.editToolbar", "Edit Toolbar", "", "{open?: bool, toolbar?: name} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar (with `toolbar`, beside that toolbar made with New Toolbar, which shows), as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state"), - ("toolbar.addTool", "Toolbar: Add Tool", "", "{tool: id, slot?: n, place?: \\\"before\\\"|\\\"after\\\"|\\\"group\\\", toolbar?: name} put `tool` in the current (Basic or Advanced) toolbar (with `toolbar`, in that toolbar made with New Toolbar), moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)"), - ("toolbar.removeTool", "Toolbar: Remove Tool", "", "{tool: id, group?: bool, toolbar?: name} take `tool` (with `group`, its whole group) out of the current toolbar (with `toolbar`, out of that toolbar made with New Toolbar; one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots"), - ("toolbar.reset", "Toolbar: Reset", "", "{toolbar?: name} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes (with `toolbar`, make that toolbar made with New Toolbar blank again); returns its slots"), - ("window.toolbar.new", "New Toolbar…", "", "{name?: string} make a blank toolbar called `name`, of at most 200 characters (omitted: a dialog asks for the name), floating beside the Tools panel and listed under Window › Toolbars; its ••• button opens the All Tools drawer to fill it (window.editToolbar with `toolbar`); it is kept with the current workspace; returns {toolbar: name}"), - ("window.toolbar.custom", "Show Toolbar", "", "{name: string, show?: bool} show (true), hide (false) or toggle (omitted) the toolbar made with New Toolbar called `name`, as its item under Window › Toolbars does (its title bar's × hides it); shown after Tab hid the toolbars, it shows alone and the others are turned off; returns the new state"), - ("window.toolbar.manage", "Manage Toolbars…", "", "{} open the Manage Toolbars dialog, which lists the toolbars made with New Toolbar: a name typed for the selected one renames it, + copies it (none selected: makes a blank toolbar) and the trash can deletes it"), - ("window.toolbar.rename", "Rename Toolbar", "", "{name: string, to: string} give the toolbar made with New Toolbar called `name` the name `to` (not empty, at most 200 characters, not another toolbar's whatever the case), as typing a name in Manage Toolbars does; returns {toolbar: name}"), - ("window.toolbar.duplicate", "Duplicate Toolbar", "", "{name?: string} copy the toolbar made with New Toolbar called `name`, with its tools, as \\\"`name` Copy\\\" (omitted: make a blank toolbar called \\\"Toolbar N\\\"), as Manage Toolbars' + does; the copy shows and is kept with the current workspace; returns {toolbar: name}"), - ("window.toolbar.delete", "Delete Toolbar", "", "{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does"), + ( + "window.toolsBesideCanvas", + "Keep Tools Beside the Canvas", + "", + "{on?: bool} dock the Tools panel between the left-side panels and the canvas (true) or at the window's left edge (false, the default), or toggle (omitted or null; any other value is an error); a floating Tools panel stays floating and docks at the chosen place when it is docked again. Kept with the workspace; a workspace saved without it docks Tools at the window's edge. Returns the new state", + ), + ( + "window.floatTools", + "Float Tool Group", + "", + "{tool: id, floating?: bool, toolbar?: name} float the toolbar group holding `tool` (in the current Basic or Advanced layout; with `toolbar`, in that toolbar made with New Toolbar) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state", + ), + ( + "toolbar.show", + "Toolbar: Show Controls", + "", + "{control?: \"fillStroke\"|\"coloring\"|\"drawMode\"|\"screenMode\", on?: bool} show (true), hide (false) or toggle (omitted) one of the controls under the toolbar's tools (Fill and Stroke, Coloring, Draw Mode, Screen Mode), as the All Tools drawer's Show section does; saved with the preferences; returns every control's state", + ), + ( + "window.editToolbar", + "Edit Toolbar", + "", + "{open?: bool, toolbar?: name} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar (with `toolbar`, beside that toolbar made with New Toolbar, which shows), as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state", + ), + ( + "toolbar.addTool", + "Toolbar: Add Tool", + "", + "{tool: id, slot?: n, place?: \"before\"|\"after\"|\"group\", toolbar?: name} put `tool` in the current (Basic or Advanced) toolbar (with `toolbar`, in that toolbar made with New Toolbar), moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)", + ), + ( + "toolbar.removeTool", + "Toolbar: Remove Tool", + "", + "{tool: id, group?: bool, toolbar?: name} take `tool` (with `group`, its whole group) out of the current toolbar (with `toolbar`, out of that toolbar made with New Toolbar; one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots", + ), + ( + "toolbar.reset", + "Toolbar: Reset", + "", + "{toolbar?: name} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes (with `toolbar`, make that toolbar made with New Toolbar blank again); returns its slots", + ), + ( + "window.toolbar.new", + "New Toolbar…", + "", + "{name?: string} make a blank toolbar called `name`, of at most 200 characters (omitted: a dialog asks for the name), floating beside the Tools panel and listed under Window › Toolbars; its ••• button opens the All Tools drawer to fill it (window.editToolbar with `toolbar`); it is kept with the current workspace; returns {toolbar: name}", + ), + ( + "window.toolbar.custom", + "Show Toolbar", + "", + "{name: string, show?: bool} show (true), hide (false) or toggle (omitted) the toolbar made with New Toolbar called `name`, as its item under Window › Toolbars does (its title bar's × hides it); shown after Tab hid the toolbars, it shows alone and the others are turned off; returns the new state", + ), + ( + "window.toolbar.manage", + "Manage Toolbars…", + "", + "{} open the Manage Toolbars dialog, which lists the toolbars made with New Toolbar: a name typed for the selected one renames it, + copies it (none selected: makes a blank toolbar) and the trash can deletes it", + ), + ( + "window.toolbar.rename", + "Rename Toolbar", + "", + "{name: string, to: string} give the toolbar made with New Toolbar called `name` the name `to` (not empty, at most 200 characters, not another toolbar's whatever the case), as typing a name in Manage Toolbars does; returns {toolbar: name}", + ), + ( + "window.toolbar.duplicate", + "Duplicate Toolbar", + "", + "{name?: string} copy the toolbar made with New Toolbar called `name`, with its tools, as \"`name` Copy\" (omitted: make a blank toolbar called \"Toolbar N\"), as Manage Toolbars' + does; the copy shows and is kept with the current workspace; returns {toolbar: name}", + ), + ( + "window.toolbar.delete", + "Delete Toolbar", + "", + "{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does", + ), ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"), ("window.taskBar", "Contextual Task Bar", "", "{}"), - ("window.pathfinder.compoundShapes", "Shape Mode Buttons Make Compound Shapes", "", "{} toggles the Compound Shapes add-on's setting: on, the Pathfinder panel's Shape Mode buttons make a compound shape and Alt-click gives the permanent Pathfinder result; off (the default), the other way round. Members of a compound shape selected always take the mode"), - ("window.taskBar.pin", "Pin Bar Position", "", "{pinned?: bool} keep the Contextual Task Bar where it is instead of following the selection (true), let it follow the selection again from where it is (false) or toggle (omitted), as the bar's More Options menu does; returns the new state"), + ( + "window.pathfinder.compoundShapes", + "Shape Mode Buttons Make Compound Shapes", + "", + "{} toggles the Compound Shapes add-on's setting: on, the Pathfinder panel's Shape Mode buttons make a compound shape and Alt-click gives the permanent Pathfinder result; off (the default), the other way round. Members of a compound shape selected always take the mode", + ), + ( + "window.taskBar.pin", + "Pin Bar Position", + "", + "{pinned?: bool} keep the Contextual Task Bar where it is instead of following the selection (true), let it follow the selection again from where it is (false) or toggle (omitted), as the bar's More Options menu does; returns the new state", + ), ("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"), - ("window.dock", "Panels", "Tab", "{} hide the panels, the toolbar and the Control bar, or show again the ones the last Tab hid (all three when none of them shows and Tab hid none); returns whether it showed them"), - ("window.panels", "Panels Except Tools and Control", "Shift+Tab", "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show"), - ("window.panel", "Show Panel", "", "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \\\"Layers\\\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was"), - ("window.panel.layout", "Change Panel Layout", "", "{action} serialized docking action, including floating geometry, divider sizes and accordion state"), - ("window.panel.move", "Move Panel", "", "{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel, group?: bool, placement?} move a panel (with `group`, its whole group) into the group holding `anchor`: as a tab (before the `before` panel; null appends), a split beside it, or (`above` / `below`) a group of its own above or below it in its dock column or floating window; `placement` (a serialized Placement) replaces zone and before"), + ( + "window.dock", + "Panels", + "Tab", + "{} hide the panels, the toolbar and the Control bar, or show again the ones the last Tab hid (all three when none of them shows and Tab hid none); returns whether it showed them", + ), + ( + "window.panels", + "Panels Except Tools and Control", + "Shift+Tab", + "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", + ), + ( + "window.panel", + "Show Panel", + "", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + ), + ( + "window.panel.layout", + "Change Panel Layout", + "", + "{action} serialized docking action, including floating geometry, divider sizes and accordion state", + ), + ( + "window.panel.move", + "Move Panel", + "", + "{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel, group?: bool, placement?} move a panel (with `group`, its whole group) into the group holding `anchor`: as a tab (before the `before` panel; null appends), a split beside it, or (`above` / `below`) a group of its own above or below it in its dock column or floating window; `placement` (a serialized Placement) replaces zone and before", + ), ("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"), ("window.panel.close", "Close Panel", "", "{panel} hide a panel"), - ("window.panel.collapse", "Collapse Panel Group", "", "{panel, collapsed?: bool} minimize the group holding a panel to its tabs (in a floating window, the window to its title bar), or restore it, as a double-click on a tab or the drag bar does; the same as window.panel.minimize {panel, minimized}. Without `collapsed` it toggles. Returns {panel, collapsed, layout}"), - ("window.panel.float", "Float Panel", "", "{panel: id or \\\"tools\\\", x?, y?, width?, height?, group?: bool, column?: bool or n, onto?: id, above?: id, below?: id, collapsed?: bool} float a panel (with `group`, its whole group; with `column`, its whole dock column or floating window) as a floating window with its top-left corner at x, y in window points (default: inside the dock area, cascaded), as dragging its tab out does; a whole floating window keeps its rect unless given one. `onto` makes it a tab of the floating group holding that panel instead; `above` / `below` a group of its own above or below the group holding that panel, in its floating window or dock column. `collapsed` then minimizes (true) or restores (false) its window or group. A hidden panel shows first. \\\"tools\\\" floats the Tools panel at x, y. Returns {layout, closed, floating}"), - ("window.panel.dock", "Dock Panel", "", "{panel: id or \\\"tools\\\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \\\"tools\\\" docks the Tools panel at the left edge of the window, or beside the canvas inside any left-side dock while window.toolsBesideCanvas is on; with side: right (or End) on the right, between the canvas and the panels, as dropping it there or on the window's right edge does (default: the side it docked at last; left or Start docks it on the left), returning {panel, floating, pos, side}. Returns {layout, closed, floating}"), - ("window.collapseDock", "Collapse Panels to Icons", "", "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state"), + ( + "window.panel.collapse", + "Collapse Panel Group", + "", + "{panel, collapsed?: bool} minimize the group holding a panel to its tabs (in a floating window, the window to its title bar), or restore it, as a double-click on a tab or the drag bar does; the same as window.panel.minimize {panel, minimized}. Without `collapsed` it toggles. Returns {panel, collapsed, layout}", + ), + ( + "window.panel.float", + "Float Panel", + "", + "{panel: id or \"tools\", x?, y?, width?, height?, group?: bool, column?: bool or n, onto?: id, above?: id, below?: id, collapsed?: bool} float a panel (with `group`, its whole group; with `column`, its whole dock column or floating window) as a floating window with its top-left corner at x, y in window points (default: inside the dock area, cascaded), as dragging its tab out does; a whole floating window keeps its rect unless given one. `onto` makes it a tab of the floating group holding that panel instead; `above` / `below` a group of its own above or below the group holding that panel, in its floating window or dock column. `collapsed` then minimizes (true) or restores (false) its window or group. A hidden panel shows first. \"tools\" floats the Tools panel at x, y. Returns {layout, closed, floating}", + ), + ( + "window.panel.dock", + "Dock Panel", + "", + "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window, or beside the canvas inside any left-side dock while window.toolsBesideCanvas is on; with side: right (or End) on the right, between the canvas and the panels, as dropping it there or on the window's right edge does (default: the side it docked at last; left or Start docks it on the left), returning {panel, floating, pos, side}. Returns {layout, closed, floating}", + ), + ( + "window.collapseDock", + "Collapse Panels to Icons", + "", + "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), - ("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …): the one left keeps its layout changes, and the one switched to comes back as it was left (as saved if never changed)"), - ("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace to the way it was saved, dropping the layout changes made in it"), + ( + "window.workspace", + "Workspace", + "", + "{name} switch workspace (Essentials, Essentials Classic, Painting, …): the one left keeps its layout changes, and the one switched to comes back as it was left (as saved if never changed)", + ), + ( + "window.workspace.reset", + "Reset Essentials", + "", + "{} reset the current workspace to the way it was saved, dropping the layout changes made in it", + ), ("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"), ("window.workspace.manage", "Manage Workspaces…", "", "{}"), ("window.workspace.delete", "Delete Workspace", "", "{name}"), ("window.workspace.rename", "Rename Workspace", "", "{name, to}"), ("window.workspace.list", "List Workspaces", "", "{}"), - ("window.newWindow", "New Window", "", "{} open another view of the current document, with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own (titled with the view's number, \\\"Name:2\\\") or, when the current view floats, in a floating window; it becomes the current window; the Arrange commands treat each view as a window; file.close closes the current view and the last view closes the document; returns {index, view}"), - ("window.arrange.cascade", "Cascade", "", "{} put every open document in a floating window of its own and stack the windows from the upper left of the document area to its lower right, 30 pt apart, in tab order, each fitted to its window, the current document's in front; on the desktop each is an OS window, on the web and Wayland a window inside the app's window; returns {floating} (document indices)"), - ("window.arrange.tile", "Tile", "", "{} show every open document and each further view of one (window.newWindow) at once, edge to edge in the document area, one pane each in a grid (columns: the square root of the number of panes, rounded up), each fitted to its pane; the current window's pane takes the tools, a click in another pane makes its window the current one; the tabs stay; windows floating come back under the tabs first; with fewer than two windows open the view stays as it is; returns {tiled, panes}"), - ("window.arrange.floatInWindow", "Float in Window", "", "{index?, view?, at?: [x, y]} move the current window, or document `index`'s view `view` (its current view without one; it becomes the current window), out of the tab strip into a floating window of its own, fitted to it, with the window's top-left corner at `at` (main window points) or else at the next place of a cascade; dragging a document tab more than 24 pt above or below the tab strip does this at the pointer; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)"), - ("window.arrange.floatAllInWindows", "Float All in Windows", "", "{} move every document under the tab strip into a floating window of its own, at the next places of a cascade; returns {floating} (document indices)"), - ("window.arrange.consolidateAllWindows", "Consolidate All Windows", "", "{index?, view?} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}; with `index`, only document `index` (its view `view`, else its current view) goes back under its tab and becomes the current one, the other windows and Tile staying as they are, as when a document window is dragged by its title bar (an OS window by the tab at its top) onto the tab strip; returns {tiled, floating} (document indices)"), - ("window.nextDocument", "Next Document", "Cmd+F6", "{} make the open document after the current one in tab order the current one, as a click on its tab does (after the last document it goes to the first, and from the Home screen to the first); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view"), - ("window.previousDocument", "Previous Document", "Cmd+Shift+F6", "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view"), + ( + "window.newWindow", + "New Window", + "", + "{} open another view of the current document, with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own (titled with the view's number, \"Name:2\") or, when the current view floats, in a floating window; it becomes the current window; the Arrange commands treat each view as a window; file.close closes the current view and the last view closes the document; returns {index, view}", + ), + ( + "window.arrange.cascade", + "Cascade", + "", + "{} put every open document in a floating window of its own and stack the windows from the upper left of the document area to its lower right, 30 pt apart, in tab order, each fitted to its window, the current document's in front; on the desktop each is an OS window, on the web and Wayland a window inside the app's window; returns {floating} (document indices)", + ), + ( + "window.arrange.tile", + "Tile", + "", + "{} show every open document and each further view of one (window.newWindow) at once, edge to edge in the document area, one pane each in a grid (columns: the square root of the number of panes, rounded up), each fitted to its pane; the current window's pane takes the tools, a click in another pane makes its window the current one; the tabs stay; windows floating come back under the tabs first; with fewer than two windows open the view stays as it is; returns {tiled, panes}", + ), + ( + "window.arrange.floatInWindow", + "Float in Window", + "", + "{index?, view?, at?: [x, y]} move the current window, or document `index`'s view `view` (its current view without one; it becomes the current window), out of the tab strip into a floating window of its own, fitted to it, with the window's top-left corner at `at` (main window points) or else at the next place of a cascade; dragging a document tab more than 24 pt above or below the tab strip does this at the pointer; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)", + ), + ( + "window.arrange.floatAllInWindows", + "Float All in Windows", + "", + "{} move every document under the tab strip into a floating window of its own, at the next places of a cascade; returns {floating} (document indices)", + ), + ( + "window.arrange.consolidateAllWindows", + "Consolidate All Windows", + "", + "{index?, view?} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}; with `index`, only document `index` (its view `view`, else its current view) goes back under its tab and becomes the current one, the other windows and Tile staying as they are, as when a document window is dragged by its title bar (an OS window by the tab at its top) onto the tab strip; returns {tiled, floating} (document indices)", + ), + ( + "window.nextDocument", + "Next Document", + "Cmd+F6", + "{} make the open document after the current one in tab order the current one, as a click on its tab does (after the last document it goes to the first, and from the Home screen to the first); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", + ), + ( + "window.previousDocument", + "Previous Document", + "Cmd+Shift+F6", + "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", + ), ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), - ("tool.setOption", "Tool Option", "", "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen"), - ("effect.dialog", "Effect…", "", "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item), aligned?: bool (index and item are rows of the first selected object's stack, which OK edits in each object where it has them, as effect.setParams does)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \\\"effectExists\\\"}"), + ( + "tool.setOption", + "Tool Option", + "", + "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen", + ), + ( + "effect.dialog", + "Effect…", + "", + "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item), aligned?: bool (index and item are rows of the first selected object's stack, which OK edits in each object where it has them, as effect.setParams does)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}", + ), ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), - ("ui.recolorDialog", "Recolor Artwork…", "", "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name; or \\\"document\\\" (Limit to Library; \\\"\\\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)"), - ("ui.cropImage", "Crop Image", "", "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \\\"rect\\\", value: [x, y, width, height]} sets the box"), + ( + "ui.recolorDialog", + "Recolor Artwork…", + "", + "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name; or \"document\" (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", + ), + ( + "ui.cropImage", + "Crop Image", + "", + "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", + ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), - ("pathfinder.repeat", "Repeat Pathfinder", "Cmd+4", "{} repeat the last successful Pathfinder panel operation, direct object.pathfinder.* command or command.batch step on the selection with current Pathfinder Options; an error until one has run"), - ("file.export.pdf", "Save as PDF…", "", "{} opens the Save PDF dialog; with params = document.exportPdf options written to path (asked when missing; viewAfterSaving opens the file) → {path, bytes, warnings}"), + ( + "pathfinder.repeat", + "Repeat Pathfinder", + "Cmd+4", + "{} repeat the last successful Pathfinder panel operation, direct object.pathfinder.* command or command.batch step on the selection with current Pathfinder Options; an error until one has run", + ), + ( + "file.export.pdf", + "Save as PDF…", + "", + "{} opens the Save PDF dialog; with params = document.exportPdf options written to path (asked when missing; viewAfterSaving opens the file) → {path, bytes, warnings}", + ), ("help.about", "About VectorCraft", "", "{}"), ("help.checkForUpdates", "Check for Updates…", "", "{}"), ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), - ("help.safeMode", "Safe Mode…", "", "{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)"), + ( + "help.safeMode", + "Safe Mode…", + "", + "{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)", + ), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), - ("ui.swatchOptions", "Swatch Options…", "", "{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit) or a gradient (the same dialog with `name` only; it shows the gradient), pattern editing for a pattern"), - ("ui.newSwatch", "New Swatch…", "", "{spot?, global?, group?: colour group name} open New Swatch for the active fill or stroke (dialog `newSwatch`, engine: swatch.new); a color opens as a spot color with `spot`, and as a global process color with `global` alone"), - ("ui.newColorGroup", "New Color Group…", "", "{swatches?: [names]} open New Color Group, from those swatches or the selected artwork (dialog `newColorGroup`, engine: swatch.newGroup)"), - ("ui.colorPicker", "Color Picker…", "", "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \\\"#rrggbb\\\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); RGB, HSB and hex values use the working RGB space; OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard"), - ("ui.sampleScreenColor", "Sample Color from Screen", "", "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open"), - ("ui.newBrush", "New Brush…", "", "{ids?} (default: the selected art) open New Brush, what the Brushes panel's New Brush button and art dropped on the panel open (dialog `newBrush`, field `type`: calligraphic|scatter|art|bristle|pattern, Art when there is art, else Calligraphic; scatter, art and pattern need art, else OK gives the error `nothing selected`); OK opens that type's Brush Options for the new brush (dialog `brushOptions` with the fields of ui.brushOptions and `__create`; name: New Brush, numbered when taken; the `ids` given in `__ids`), whose OK runs brush.new {type, name, params, ids?} (a name another brush has gets a number) and whose Cancel adds no brush"), - ("ui.brushOptions", "Brush Options…", "", "{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift, keyColor, and tips: true opens Colorization Tips (what each method does, beside a sample of the brush painted with it), as the Tips button beside Method does; OK runs brush.options"), - ("ui.graphicStyleOptions", "Graphic Style Options…", "", "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)"), - ("ui.charStyleOptions", "Character Style Options", "", "{name} open Character Style Options (dialog `charStyleOptions`); OK runs charStyle.edit with name, to and sparse attrs in one undo step; Cancel restores a live preview"), - ("ui.charStyleOptions.resetPanel", "Reset Panel", "", "{} clear the settings of the section shown, Basic Character Formats, Advanced Character Formats, Character Color or OpenType Features, in the open Character Style Options or New Character Style dialog; available only on those sections"), - ("ui.newCharStyle", "New Character Style…", "", "{} open New Character Style (dialog `newTextStyle`, field `name`, filled in with the name charStyle.new {} would give the style), what Alt-clicking the Character Styles panel's New button opens; OK runs charStyle.new with the name and the Type tool's selected characters (else the selected type) as they were when the dialog opened; Cancel adds no style"), - ("ui.newParaStyle", "New Paragraph Style…", "", "{} open New Paragraph Style (dialog `newTextStyle`, field `name`, filled in with the name paraStyle.new {} would give the style), what Alt-clicking the Paragraph Styles panel's New button opens; OK runs paraStyle.new with the name and the paragraph of the Type tool's selection (else the selected type) as it was when the dialog opened; Cancel adds no style"), - ("ui.symbolOptions", "Symbol Options…", "F8", "{name?} open Symbol Options (dialog `symbolOptions`, fields `name`, `exportType`: movieClip|graphic, `registration` 0..8). Without `name` and with a selection: New Symbol (default name the first free \\\"New Symbol\\\"/\\\"New Symbol N\\\", Export Type Movie Clip, Registration centre); OK runs symbol.new {name, exportType, registration}. With `edit: true` or `name`: edit that symbol; OK runs symbol.options {name, newName?, exportType, registration}"), - ("tool.options", "Tool Options…", "", "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \\\"flare\\\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil and paintbrush, the same at any zoom; pt for smooth and blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \\\"livePaintSelection\\\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \\\"livePaintBucket\\\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \\\"shapeBuilder\\\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \\\"objectsOnPath\\\", values}: the objects attached next use them, groups attached before keep theirs)"), - ("ui.colorGuideOptions", "Color Guide Options…", "", "{} open Color Guide Options (dialog `colorGuideOptions`, fields `steps` 1–20 and `amount` 0–100): OK sets the Color Guide panel's variation grid (read back with `ui.inspect`: ui.color_guide; engine: color.harmony)"), - ("ui.colorBalanceDialog", "Adjust Color Balance…", "", "{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray / tint (global) −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step"), - ("ui.saturateDialog", "Saturate…", "", "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step"), - ("window.swatchLibrary", "Swatch Library", "", "{library: id or name (see swatch.library.list), or the id of a persistent panel's missing library | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}"), - ("window.swatchLibrary.other", "Other Library…", "", "{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in its library panel"), - ("ui.libraryPanel", "Library Panel Options", "", "{panel: a library panel's id (`library:swatches:pastels`, as window.swatchLibrary answers), library?: id or name of another library of its kind to show in the panel (Previous / Next Library; one open in a panel of its own is brought forward instead), view?: smallThumb|mediumThumb|largeThumb|smallList|largeList, showFind?: bool, persistent?: bool (reopen the panel when the app starts)} set an open library panel, or a closed persistent one's view, Show Find Field and Persistent; kept with the preferences (ui.inspect: ui.library_panels) → {panel, kind, library, view, showFind, persistent, open}"), - ("ui.libraryPanel.locate", "Locate Library…", "", "{panel: an open library panel's id, path?} (default: pick a file) Locate Library… of a persistent library panel whose library's file is missing: load the library from the file, as Other Library… does, and show it in the panel, which keeps its view, Show Find Field and Persistent. When the file's library has a panel open already, the panel closes and that one is brought forward, made persistent when the closed one was → what ui.libraryPanel answers"), - ("ui.saveSwatchLibrary", "Save Swatch Library…", "", "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|ase|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save"), + ( + "ui.swatchOptions", + "Swatch Options…", + "", + "{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit) or a gradient (the same dialog with `name` only; it shows the gradient), pattern editing for a pattern", + ), + ( + "ui.newSwatch", + "New Swatch…", + "", + "{spot?, global?, group?: colour group name} open New Swatch for the active fill or stroke (dialog `newSwatch`, engine: swatch.new); a color opens as a spot color with `spot`, and as a global process color with `global` alone", + ), + ( + "ui.newColorGroup", + "New Color Group…", + "", + "{swatches?: [names]} open New Color Group, from those swatches or the selected artwork (dialog `newColorGroup`, engine: swatch.newGroup)", + ), + ( + "ui.colorPicker", + "Color Picker…", + "", + "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); RGB, HSB and hex values use the working RGB space; OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard", + ), + ( + "ui.sampleScreenColor", + "Sample Color from Screen", + "", + "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open", + ), + ( + "ui.newBrush", + "New Brush…", + "", + "{ids?} (default: the selected art) open New Brush, what the Brushes panel's New Brush button and art dropped on the panel open (dialog `newBrush`, field `type`: calligraphic|scatter|art|bristle|pattern, Art when there is art, else Calligraphic; scatter, art and pattern need art, else OK gives the error `nothing selected`); OK opens that type's Brush Options for the new brush (dialog `brushOptions` with the fields of ui.brushOptions and `__create`; name: New Brush, numbered when taken; the `ids` given in `__ids`), whose OK runs brush.new {type, name, params, ids?} (a name another brush has gets a number) and whose Cancel adds no brush", + ), + ( + "ui.brushOptions", + "Brush Options…", + "", + "{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift, keyColor, and tips: true opens Colorization Tips (what each method does, beside a sample of the brush painted with it), as the Tips button beside Method does; OK runs brush.options", + ), + ( + "ui.graphicStyleOptions", + "Graphic Style Options…", + "", + "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)", + ), + ( + "ui.charStyleOptions", + "Character Style Options", + "", + "{name} open Character Style Options (dialog `charStyleOptions`); OK runs charStyle.edit with name, to and sparse attrs in one undo step; Cancel restores a live preview", + ), + ( + "ui.charStyleOptions.resetPanel", + "Reset Panel", + "", + "{} clear the settings of the section shown, Basic Character Formats, Advanced Character Formats, Character Color or OpenType Features, in the open Character Style Options or New Character Style dialog; available only on those sections", + ), + ( + "ui.newCharStyle", + "New Character Style…", + "", + "{} open New Character Style (dialog `newTextStyle`, field `name`, filled in with the name charStyle.new {} would give the style), what Alt-clicking the Character Styles panel's New button opens; OK runs charStyle.new with the name and the Type tool's selected characters (else the selected type) as they were when the dialog opened; Cancel adds no style", + ), + ( + "ui.newParaStyle", + "New Paragraph Style…", + "", + "{} open New Paragraph Style (dialog `newTextStyle`, field `name`, filled in with the name paraStyle.new {} would give the style), what Alt-clicking the Paragraph Styles panel's New button opens; OK runs paraStyle.new with the name and the paragraph of the Type tool's selection (else the selected type) as it was when the dialog opened; Cancel adds no style", + ), + ( + "ui.symbolOptions", + "Symbol Options…", + "F8", + "{name?} open Symbol Options (dialog `symbolOptions`, fields `name`, `exportType`: movieClip|graphic, `registration` 0..8). Without `name` and with a selection: New Symbol (default name the first free \"New Symbol\"/\"New Symbol N\", Export Type Movie Clip, Registration centre); OK runs symbol.new {name, exportType, registration}. With `edit: true` or `name`: edit that symbol; OK runs symbol.options {name, newName?, exportType, registration}", + ), + ( + "tool.options", + "Tool Options…", + "", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil and paintbrush, the same at any zoom; pt for smooth and blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", + ), + ( + "ui.colorGuideOptions", + "Color Guide Options…", + "", + "{} open Color Guide Options (dialog `colorGuideOptions`, fields `steps` 1–20 and `amount` 0–100): OK sets the Color Guide panel's variation grid (read back with `ui.inspect`: ui.color_guide; engine: color.harmony)", + ), + ( + "ui.colorBalanceDialog", + "Adjust Color Balance…", + "", + "{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray / tint (global) −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step", + ), + ( + "ui.saturateDialog", + "Saturate…", + "", + "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", + ), + ( + "window.swatchLibrary", + "Swatch Library", + "", + "{library: id or name (see swatch.library.list), or the id of a persistent panel's missing library | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}", + ), + ( + "window.swatchLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in its library panel", + ), + ( + "ui.libraryPanel", + "Library Panel Options", + "", + "{panel: a library panel's id (`library:swatches:pastels`, as window.swatchLibrary answers), library?: id or name of another library of its kind to show in the panel (Previous / Next Library; one open in a panel of its own is brought forward instead), view?: smallThumb|mediumThumb|largeThumb|smallList|largeList, showFind?: bool, persistent?: bool (reopen the panel when the app starts)} set an open library panel, or a closed persistent one's view, Show Find Field and Persistent; kept with the preferences (ui.inspect: ui.library_panels) → {panel, kind, library, view, showFind, persistent, open}", + ), + ( + "ui.libraryPanel.locate", + "Locate Library…", + "", + "{panel: an open library panel's id, path?} (default: pick a file) Locate Library… of a persistent library panel whose library's file is missing: load the library from the file, as Other Library… does, and show it in the panel, which keeps its view, Show Find Field and Persistent. When the file's library has a panel open already, the panel closes and that one is brought forward, made persistent when the closed one was → what ui.libraryPanel answers", + ), + ( + "ui.saveSwatchLibrary", + "Save Swatch Library…", + "", + "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|ase|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save", + ), ("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"), ("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"), ("window.userSwatchLibrary3", "User Swatch Library 3", "", "{} open the 3. User Defined swatch library"), @@ -279,28 +885,144 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.userSwatchLibrary8", "User Swatch Library 8", "", "{} open the 8. User Defined swatch library"), ("window.userSwatchLibrary9", "User Swatch Library 9", "", "{} open the 9. User Defined swatch library"), ("window.userSwatchLibrary10", "User Swatch Library 10", "", "{} open the 10. User Defined swatch library"), - ("ui.mergeGraphicStyles", "Merge Graphic Styles…", "", "{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)"), - ("ui.layerOptions", "Options for Selection…", "", "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \\\"Light Blue\\\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step); for several rows it sends only the fields set in the dialog"), - ("ui.rename", "Rename", "", "{id?} open the Layers panel's name field on the row of layer, group or object id, as double-clicking the row's name does (the panel is shown and the layers around the row open): Enter renames it with layer.setProps (one undo step), Escape keeps the name. Without id: the highlighted row when a Layers panel row was clicked last, else the one selected object or group, else the one highlighted row, else the current layer. Several rows or objects, or a row the panel's search, filter or Show Layers Only hides, is an error and nothing opens → {id, name}"), - ("ui.newLayer", "New Layer Options…", "Cmd+Alt+L", "{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields"), - ("ui.newArtboard", "New Artboard Options…", "", "{} open Artboard Options for a new artboard, filled in as artboard.new {} would make it (Artboard N, the last artboard's size, 20 pt to its right): dialog `artboardOptions` with new: true, fields name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.new with the fields and makes the new artboard the active one; Cancel adds nothing"), - ("artboard.selectAll", "Select All Artboards", "", "{} the Artboard tool, chosen first when another tool is in use, selects every artboard, and the active artboard stays active; the art's selection stays as it is, and no undo step is recorded → {selected: [index…]} (the active artboard first)"), - ("ui.layersPanelOptions", "Panel Options…", "", "{} open the Layers panel's Panel Options: dialog `layersPanelOptions`, fields layersOnly, locateSelected (Locate Selected Objects, off by default: when the last selected object changes outside the panel, on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as after layer.locate, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off; a change made during a drag is located when the drag ends), rowSize (small|medium|large|other), otherSize (12–100 pt), thumbLayers, thumbGroups, thumbObjects; OK applies them (kept with the UI state)"), - ("ui.navigatorPanelOptions", "Panel Options…", "", "{color?: #rrggbb or a preset name (Light Red, the default, then the layer colors such as \\\"Light Blue\\\"), solidDashes?: bool (Draw Dashed Lines as Solid Lines, off by default: the Navigator's thumbnail draws dashed strokes without their dashes; the canvas, printing and exports keep them)} set the Navigator panel's options, the color its view box is drawn in and solid dashes (kept with the UI state) → {color, solidDashes}; with neither, open its Panel Options (dialog `navigatorPanelOptions`, fields color, solidDashes; OK sets them); an error for a value that is not a color or not true or false, which changes nothing"), - ("ui.layersExpand", "Expand Layers Panel Rows", "", "{ids?: [id…] (default: every row that holds others), open?: bool (default true)} open or close rows of the Layers panel, as clicking their triangles does → {count}"), - ("ui.layersFilter", "Filter Layers Panel Rows", "", "{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}"), - ("ui.tileEdgeColor", "Tile Edge Color…", "", "{} open Tile Edge Color (dialog `tileEdgeColor`, field `color`: #rrggbb or a preset name such as \\\"Light Blue\\\"); OK sets the preference patternTileEdgeColor (prefs.set), the colour of the tile edge and swatch bounds in pattern editing mode; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing"), - ("ui.flattenTransparencyDialog", "Flatten Transparency…", "", "{} open Flatten Transparency for the selection (dialog `flattenTransparency`, fields `preset` (a preset name: setting it loads its options), balance 0..100, lineArtPpi, gradientPpi 1..2400, textToOutlines, strokesToOutlines, clipComplexRegions, antiAlias, preserveAlpha, preserveOverprints, `preview` (off at first)): OK runs object.flattenTransparency with those options as one undo step"), - ("ui.flattenerPresetsDialog", "Transparency Flattener Presets…", "", "{selected?: preset name} open the presets manager (dialog `flattenerPresets`, fields `selected`, then the selected preset's `name` and option keys: setting them on a saved preset saves it, a new `name` renames it; built-in presets don't change). New, Delete, Import… and Export… run flattener.presets.save / delete / import / export"), - ("ui.imageTracePresetDialog", "Save as New Preset…", "", "{params?: tracing settings (default: the Default preset's), rename?: a saved preset's name} the Image Trace panel's Manage Presets: open Save as New Preset… for those settings, or with `rename`, Rename… for that saved preset (dialog `imageTracePreset`, field `name`: a new \\\"Tracing Preset N\\\" or the preset's name); OK runs imageTrace.presets.save with the name (as `newName` when renaming), and the panel shows the preset it made from its settings"), - ("ui.flattenerPreview", "Flattener Preview", "", "{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it"), - ("window.graphicStyleLibrary", "Graphic Style Library", "", "{library: id or name (see graphicStyle.libraries), or the id of a persistent panel's missing library | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}"), - ("window.graphicStyleLibrary.other", "Other Library…", "", "{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in its library panel"), - ("ui.saveGraphicStyleLibrary", "Save Graphic Style Library…", "", "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary"), - ("ui.loadCharStyles", "Load Character Styles…", "", "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character styles to the active one (engine: charStyle.load), the Character Styles panel menu's Load Character Styles…"), - ("ui.loadParaStyles", "Load Paragraph Styles…", "", "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's paragraph styles to the active one (engine: paraStyle.load), the Paragraph Styles panel menu's Load Paragraph Styles…"), - ("ui.loadAllStyles", "Load All Styles…", "", "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character and paragraph styles to the active one (engine: textStyle.loadAll), Load All Styles… in either panel's menu"), - ("window.userGraphicStyleLibrary1", "User Graphic Style Library 1", "", "{} open the 1. User Defined graphic style library (graphicStyle.libraries, category user)"), + ( + "ui.mergeGraphicStyles", + "Merge Graphic Styles…", + "", + "{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)", + ), + ( + "ui.layerOptions", + "Options for Selection…", + "", + "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step); for several rows it sends only the fields set in the dialog", + ), + ( + "ui.rename", + "Rename", + "", + "{id?} open the Layers panel's name field on the row of layer, group or object id, as double-clicking the row's name does (the panel is shown and the layers around the row open): Enter renames it with layer.setProps (one undo step), Escape keeps the name. Without id: the highlighted row when a Layers panel row was clicked last, else the one selected object or group, else the one highlighted row, else the current layer. Several rows or objects, or a row the panel's search, filter or Show Layers Only hides, is an error and nothing opens → {id, name}", + ), + ( + "ui.newLayer", + "New Layer Options…", + "Cmd+Alt+L", + "{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields", + ), + ( + "ui.newArtboard", + "New Artboard Options…", + "", + "{} open Artboard Options for a new artboard, filled in as artboard.new {} would make it (Artboard N, the last artboard's size, 20 pt to its right): dialog `artboardOptions` with new: true, fields name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.new with the fields and makes the new artboard the active one; Cancel adds nothing", + ), + ( + "artboard.selectAll", + "Select All Artboards", + "", + "{} the Artboard tool, chosen first when another tool is in use, selects every artboard, and the active artboard stays active; the art's selection stays as it is, and no undo step is recorded → {selected: [index…]} (the active artboard first)", + ), + ( + "ui.layersPanelOptions", + "Panel Options…", + "", + "{} open the Layers panel's Panel Options: dialog `layersPanelOptions`, fields layersOnly, locateSelected (Locate Selected Objects, off by default: when the last selected object changes outside the panel, on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as after layer.locate, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off; a change made during a drag is located when the drag ends), rowSize (small|medium|large|other), otherSize (12–100 pt), thumbLayers, thumbGroups, thumbObjects; OK applies them (kept with the UI state)", + ), + ( + "ui.navigatorPanelOptions", + "Panel Options…", + "", + "{color?: #rrggbb or a preset name (Light Red, the default, then the layer colors such as \"Light Blue\"), solidDashes?: bool (Draw Dashed Lines as Solid Lines, off by default: the Navigator's thumbnail draws dashed strokes without their dashes; the canvas, printing and exports keep them)} set the Navigator panel's options, the color its view box is drawn in and solid dashes (kept with the UI state) → {color, solidDashes}; with neither, open its Panel Options (dialog `navigatorPanelOptions`, fields color, solidDashes; OK sets them); an error for a value that is not a color or not true or false, which changes nothing", + ), + ( + "ui.layersExpand", + "Expand Layers Panel Rows", + "", + "{ids?: [id…] (default: every row that holds others), open?: bool (default true)} open or close rows of the Layers panel, as clicking their triangles does → {count}", + ), + ( + "ui.layersFilter", + "Filter Layers Panel Rows", + "", + "{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}", + ), + ( + "ui.tileEdgeColor", + "Tile Edge Color…", + "", + "{} open Tile Edge Color (dialog `tileEdgeColor`, field `color`: #rrggbb or a preset name such as \"Light Blue\"); OK sets the preference patternTileEdgeColor (prefs.set), the colour of the tile edge and swatch bounds in pattern editing mode; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing", + ), + ( + "ui.flattenTransparencyDialog", + "Flatten Transparency…", + "", + "{} open Flatten Transparency for the selection (dialog `flattenTransparency`, fields `preset` (a preset name: setting it loads its options), balance 0..100, lineArtPpi, gradientPpi 1..2400, textToOutlines, strokesToOutlines, clipComplexRegions, antiAlias, preserveAlpha, preserveOverprints, `preview` (off at first)): OK runs object.flattenTransparency with those options as one undo step", + ), + ( + "ui.flattenerPresetsDialog", + "Transparency Flattener Presets…", + "", + "{selected?: preset name} open the presets manager (dialog `flattenerPresets`, fields `selected`, then the selected preset's `name` and option keys: setting them on a saved preset saves it, a new `name` renames it; built-in presets don't change). New, Delete, Import… and Export… run flattener.presets.save / delete / import / export", + ), + ( + "ui.imageTracePresetDialog", + "Save as New Preset…", + "", + "{params?: tracing settings (default: the Default preset's), rename?: a saved preset's name} the Image Trace panel's Manage Presets: open Save as New Preset… for those settings, or with `rename`, Rename… for that saved preset (dialog `imageTracePreset`, field `name`: a new \"Tracing Preset N\" or the preset's name); OK runs imageTrace.presets.save with the name (as `newName` when renaming), and the panel shows the preset it made from its settings", + ), + ( + "ui.flattenerPreview", + "Flattener Preview", + "", + "{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it", + ), + ( + "ui.copyScriptConsole", + "Copy Script Console", + "", + "{first?, last?: numbers of lines the Script Console holds, both or neither} copy those lines of the Script Console to the clipboard as the panel shows them, each with its run's number and script name; without them every line, after a line counting the lines it dropped and cut when it dropped or cut any, which n does not count (Script Console panel: Copy Selected, Copy All; script.console gives the lines) → {lines: n}", + ), + ( + "window.graphicStyleLibrary", + "Graphic Style Library", + "", + "{library: id or name (see graphicStyle.libraries), or the id of a persistent panel's missing library | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}", + ), + ( + "window.graphicStyleLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in its library panel", + ), + ( + "ui.saveGraphicStyleLibrary", + "Save Graphic Style Library…", + "", + "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary", + ), + ( + "ui.loadCharStyles", + "Load Character Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character styles to the active one (engine: charStyle.load), the Character Styles panel menu's Load Character Styles…", + ), + ( + "ui.loadParaStyles", + "Load Paragraph Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's paragraph styles to the active one (engine: paraStyle.load), the Paragraph Styles panel menu's Load Paragraph Styles…", + ), + ( + "ui.loadAllStyles", + "Load All Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character and paragraph styles to the active one (engine: textStyle.loadAll), Load All Styles… in either panel's menu", + ), + ( + "window.userGraphicStyleLibrary1", + "User Graphic Style Library 1", + "", + "{} open the 1. User Defined graphic style library (graphicStyle.libraries, category user)", + ), ("window.userGraphicStyleLibrary2", "User Graphic Style Library 2", "", "{} open the 2. User Defined graphic style library"), ("window.userGraphicStyleLibrary3", "User Graphic Style Library 3", "", "{} open the 3. User Defined graphic style library"), ("window.userGraphicStyleLibrary4", "User Graphic Style Library 4", "", "{} open the 4. User Defined graphic style library"), @@ -310,9 +1032,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.userGraphicStyleLibrary8", "User Graphic Style Library 8", "", "{} open the 8. User Defined graphic style library"), ("window.userGraphicStyleLibrary9", "User Graphic Style Library 9", "", "{} open the 9. User Defined graphic style library"), ("window.userGraphicStyleLibrary10", "User Graphic Style Library 10", "", "{} open the 10. User Defined graphic style library"), - ("window.symbolLibrary", "Symbol Library", "", "{library: id or name (see symbol.libraries) | null (close)} open a symbol library in a library panel of its own (UI state `library_panels`, kind symbols); clicking a symbol there runs symbol.addFromLibrary, dragging one onto the canvas runs it with place → {open, panel, name, count}"), - ("window.symbolLibrary.other", "Other Library…", "", "{path?} (default: pick a file) load the symbols of another document or a saved symbol library (engine: symbol.loadLibrary), and open them in a library panel"), - ("ui.saveSymbolLibrary", "Save Symbols…", "", "{names?: [the symbol selected in the Symbols panel]} open Save Symbol Library (dialog `saveSymbolLibrary`: name, user: save to the user library folder, selectedOnly); OK runs symbol.saveLibrary"), + ( + "window.symbolLibrary", + "Symbol Library", + "", + "{library: id or name (see symbol.libraries) | null (close)} open a symbol library in a library panel of its own (UI state `library_panels`, kind symbols); clicking a symbol there runs symbol.addFromLibrary, dragging one onto the canvas runs it with place → {open, panel, name, count}", + ), + ( + "window.symbolLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load the symbols of another document or a saved symbol library (engine: symbol.loadLibrary), and open them in a library panel", + ), + ( + "ui.saveSymbolLibrary", + "Save Symbols…", + "", + "{names?: [the symbol selected in the Symbols panel]} open Save Symbol Library (dialog `saveSymbolLibrary`: name, user: save to the user library folder, selectedOnly); OK runs symbol.saveLibrary", + ), ("window.userSymbolLibrary1", "User Symbol Library 1", "", "{} open the 1. User Defined symbol library (symbol.libraries, category user)"), ("window.userSymbolLibrary2", "User Symbol Library 2", "", "{} open the 2. User Defined symbol library"), ("window.userSymbolLibrary3", "User Symbol Library 3", "", "{} open the 3. User Defined symbol library"), @@ -323,9 +1060,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.userSymbolLibrary8", "User Symbol Library 8", "", "{} open the 8. User Defined symbol library"), ("window.userSymbolLibrary9", "User Symbol Library 9", "", "{} open the 9. User Defined symbol library"), ("window.userSymbolLibrary10", "User Symbol Library 10", "", "{} open the 10. User Defined symbol library"), - ("window.brushLibrary", "Brush Library", "", "{library: id or name (see brush.libraries) | null (close)} open a brush library in a library panel of its own (UI state `library_panels`, kind brushes); clicking a brush there runs brush.addFromLibrary (with apply when paths are selected), dragging one onto a path runs it with apply on that path, dragging one onto the Brushes panel adds it → {open, panel, name, count}"), - ("window.brushLibrary.other", "Other Library…", "", "{path?} (default: pick a file) load the brushes of another document or a saved brush library (engine: brush.loadLibrary), and open them in a library panel"), - ("ui.saveBrushLibrary", "Save Brushes…", "", "{names?: [the brushes selected in the Brushes panel]} open Save Brush Library (dialog `saveBrushLibrary`: name, user: save to the user library folder, selectedOnly); OK runs brush.saveLibrary"), + ( + "window.brushLibrary", + "Brush Library", + "", + "{library: id or name (see brush.libraries) | null (close)} open a brush library in a library panel of its own (UI state `library_panels`, kind brushes); clicking a brush there runs brush.addFromLibrary (with apply when paths are selected), dragging one onto a path runs it with apply on that path, dragging one onto the Brushes panel adds it → {open, panel, name, count}", + ), + ( + "window.brushLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load the brushes of another document or a saved brush library (engine: brush.loadLibrary), and open them in a library panel", + ), + ( + "ui.saveBrushLibrary", + "Save Brushes…", + "", + "{names?: [the brushes selected in the Brushes panel]} open Save Brush Library (dialog `saveBrushLibrary`: name, user: save to the user library folder, selectedOnly); OK runs brush.saveLibrary", + ), ("window.userBrushLibrary1", "User Brush Library 1", "", "{} open the 1. User Defined brush library (brush.libraries, category user)"), ("window.userBrushLibrary2", "User Brush Library 2", "", "{} open the 2. User Defined brush library"), ("window.userBrushLibrary3", "User Brush Library 3", "", "{} open the 3. User Defined brush library"), @@ -336,31 +1088,156 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.userBrushLibrary8", "User Brush Library 8", "", "{} open the 8. User Defined brush library"), ("window.userBrushLibrary9", "User Brush Library 9", "", "{} open the 9. User Defined brush library"), ("window.userBrushLibrary10", "User Brush Library 10", "", "{} open the 10. User Defined brush library"), - ("ui.expandDialog", "Expand…", "", "{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step; dimmed, and an error, while the selection has an appearance effect.expandAppearance expands (object.expand.info's appearance)"), - ("attributes.openUrl", "Browser", "", "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button; http, https and mailto URLs only; not over the control channel or MCP) → {url}"), - ("ui.javascriptFilesDialog", "Link JavaScript Files…", "", "{} open JavaScript Files for the active document (dialog `javascriptFiles`, fields `file`: a path or URL and `selected`: a linked file; the SVG Interactivity panel's Link JavaScript Files button): its list shows svgInteractivity.info's files, Add runs svgInteractivity.addFile with file and Remove runs svgInteractivity.removeFile with selected"), - ("ui.svgFilterDialog", "Apply SVG Filter…", "", "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \\\"\\\", \\\"new\\\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's filters; OK runs svgFilter.apply with selected, or effect.setParams on the applied effect; New and Edit open the code, Save runs svgFilter.define, Delete runs svgFilter.delete and Import… svgFilter.import with a picked SVG file"), - ("ui.importSvgFilter", "Import SVG Filter…", "", "{} pick an SVG file and add its filters to the active document's SVG filters (svgFilter.import) → svgFilter.import's result"), - ("ui.spotColors", "Spot Colors…", "", "{} open Spot Colors (dialog `spotColors`, field `useLab`: true shows and separates Lab spot colours from their Lab values, false from their CMYK equivalents); OK runs swatch.spotOptions as one undo step"), - ("ui.menuDialog", "Menu Dialog", "", "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|path.averageJoin|object.path.offsetPath|object.path.simplify|object.path.smooth|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|artboard.rename|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options|addon.arrange.grid|addon.arrange.replace|addon.arrange.size} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, renameArtboards, envelopeWarp, envelopeMesh, envelopeOptions, arrangeGrid, arrangeReplace, arrangeSize; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)"), - ("ui.widthPointEdit", "Width Point Edit…", "", "{id, index?: existing width point, t?: 0..1 for a new point, adjustAdjoining?: bool (false)} open Width Point Edit on path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking the stroke opens it too, Shift on a width point enabling adjoining adjustment): OK runs stroke.widthPoint.set; discard: true (Delete) removes an existing point or discards a prospective point"), - ("ui.corners", "Corners…", "", "{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape"), - ("ui.simplifyAuto", "Auto-Simplify", "", "{} the open Simplify slider (Object › Path › Simplify opens it: dialog `simplifySlider` with field `level` 0..100, object.path.simplify's level, opening at the level object.path.simplify {auto: true} picks for the selection, previewed on the canvas until ui.dialog.confirm keeps it as one undo step; after OK in the Simplify dialog with `retain` ticked, the menu item opens the dialog `simplify` with the fields of that OK instead) moves to the level object.path.simplify {auto: true} picks for the selection and previews it → {level}; an error when the slider isn't open"), - ("ui.smoothAuto", "Auto-Smooth", "", "{} the open Smooth slider (Object › Path › Smooth opens it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open"), - ("ui.simplifyMoreOptions", "More Options", "", "{} the Simplify dialog (dialog `simplify`: tolerance (pt), angleThreshold, straightLines, showOriginal, retain: ticked, OK makes Object › Path › Simplify open this dialog with these fields next time) takes the open Simplify slider's place, at the slider's tolerance → {tolerance}; an error when the slider isn't open"), - ("ui.dialogReset", "Reset", "", "{} the open dialog's Reset, which its Cancel button reads while Alt (Option on macOS) is held (Flare Tool Options, dialog `flareOptions`): its fields go back to their defaults and the dialog stays open, so OK applies them → the dialog; an error when no dialog is open or the open one has no Reset"), - ("ui.justification", "Justification…", "", "{} open Justification (the Paragraph panel menu's Justification…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, with Preview (dialog `justification`: wordMin, wordDesired, wordMax 0..1000, letterMin, letterDesired, letterMax -100..500, glyphMin, glyphDesired, glyphMax 50..200 (percent), autoLeading 0..500 (percent), singleWord (fullJustify|alignLeft|alignCenter|alignRight), preview): OK runs text.setFormat with the values that changed, as one undo step, and is disabled while a row's Minimum exceeds its Desired or Desired its Maximum; an error with no type selected"), - ("ui.hyphenation", "Hyphenation…", "", "{} open Hyphenation (the Paragraph panel menu's Hyphenation…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows (dialog `hyphenation`: hyphenate, wordsLongerThan 1..25, afterFirst 1..15, beforeLast 1..15, hyphenLimit 0..25 (0: no limit), hyphenationZone (pt), hyphenateCapitalized): OK runs text.setFormat with the values that changed, as one undo step; an error with no type selected"), - ("ui.tateChuYokoSettings", "Tate-chu-yoko Settings…", "", "{} open Tate-chu-yoko Settings (the Character panel menu's Tate-chu-yoko Settings…, with Show East Asian Options) on the characters the Character panel acts on, filled in with the values they share (dialog `tateChuYokoSettings`: tcyUpDown, tcyLeftRight (pt, -1296..1296)): OK runs text.setFormat (text.setRangeStyle while the Type tool edits text) with the values that changed, as one undo step; an error with no type selected"), - ("ui.colorGuideLimit", "Limit Color Guide to Library", "", "{library: swatch library id or name (see swatch.library.list) | \\\"document\\\" (the document's swatches) | \\\"\\\" or null (no limit)} limit the Color Guide panel's colours to that library: every harmony colour and variation snaps to its nearest colour, as color.harmony {limitTo} answers (ui.inspect: ui.color_guide_limit) → {limitTo, name}"), - ("ui.colorPanel", "Color Panel Options", "", "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences; RGB, HSB, Web Safe RGB and hex values use the working RGB space (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}"), - ("ui.savePdfDialog", "Save PDF Dialog", "", "{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)"), - ("file.exportAs", "Export As…", "", "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}"), - ("ui.fileInfoDialog", "File Info Dialog", "", "{} open File Info (dialog `fileInfo`: the file.info fields, plus __keyword, keywords typed but not added yet); OK runs file.info. The File Info… menu item opens it too"), - ("ui.rasterEffectsSettingsDialog", "Document Raster Effects Settings Dialog", "", "{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too"), - ("ui.effectGalleryDialog", "Effect Gallery Dialog", "", "{} open Effect Gallery (dialog `effectGallery`: browse raster filters, build a stack, OK applies it as separate effects in one undo step). Effect → Effect Gallery… opens it too"), - ("ui.pdfPresetsDialog", "PDF Presets…", "", "{selected?: preset name} open Edit → PDF Presets (dialog `pdfPresets`, field `selected`): the built-in presets (read-only) and the saved ones, with the selected one's description and settings. New… and Edit… open the preset editor (ui.pdfPresetDialog); Delete, Import… and Export… run pdf.preset.delete / import / export"), - ("ui.pdfPresetDialog", "PDF Preset", "", "{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets"), + ( + "ui.expandDialog", + "Expand…", + "", + "{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step; dimmed, and an error, while the selection has an appearance effect.expandAppearance expands (object.expand.info's appearance)", + ), + ( + "attributes.openUrl", + "Browser", + "", + "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button; http, https and mailto URLs only; not over the control channel or MCP) → {url}", + ), + ( + "ui.javascriptFilesDialog", + "Link JavaScript Files…", + "", + "{} open JavaScript Files for the active document (dialog `javascriptFiles`, fields `file`: a path or URL and `selected`: a linked file; the SVG Interactivity panel's Link JavaScript Files button): its list shows svgInteractivity.info's files, Add runs svgInteractivity.addFile with file and Remove runs svgInteractivity.removeFile with selected", + ), + ( + "ui.svgFilterDialog", + "Apply SVG Filter…", + "", + "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's filters; OK runs svgFilter.apply with selected, or effect.setParams on the applied effect; New and Edit open the code, Save runs svgFilter.define, Delete runs svgFilter.delete and Import… svgFilter.import with a picked SVG file", + ), + ( + "ui.importSvgFilter", + "Import SVG Filter…", + "", + "{} pick an SVG file and add its filters to the active document's SVG filters (svgFilter.import) → svgFilter.import's result", + ), + ( + "ui.spotColors", + "Spot Colors…", + "", + "{} open Spot Colors (dialog `spotColors`, field `useLab`: true shows and separates Lab spot colours from their Lab values, false from their CMYK equivalents); OK runs swatch.spotOptions as one undo step", + ), + ( + "ui.menuDialog", + "Menu Dialog", + "", + "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|path.averageJoin|object.path.offsetPath|object.path.simplify|object.path.smooth|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|artboard.rename|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options|addon.arrange.grid|addon.arrange.replace|addon.arrange.size} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, renameArtboards, envelopeWarp, envelopeMesh, envelopeOptions, arrangeGrid, arrangeReplace, arrangeSize; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", + ), + ( + "ui.widthPointEdit", + "Width Point Edit…", + "", + "{id, index?: existing width point, t?: 0..1 for a new point, adjustAdjoining?: bool (false)} open Width Point Edit on path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking the stroke opens it too, Shift on a width point enabling adjoining adjustment): OK runs stroke.widthPoint.set; discard: true (Delete) removes an existing point or discards a prospective point", + ), + ( + "ui.corners", + "Corners…", + "", + "{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape", + ), + ( + "ui.simplifyAuto", + "Auto-Simplify", + "", + "{} the open Simplify slider (Object › Path › Simplify opens it: dialog `simplifySlider` with field `level` 0..100, object.path.simplify's level, opening at the level object.path.simplify {auto: true} picks for the selection, previewed on the canvas until ui.dialog.confirm keeps it as one undo step; after OK in the Simplify dialog with `retain` ticked, the menu item opens the dialog `simplify` with the fields of that OK instead) moves to the level object.path.simplify {auto: true} picks for the selection and previews it → {level}; an error when the slider isn't open", + ), + ( + "ui.smoothAuto", + "Auto-Smooth", + "", + "{} the open Smooth slider (Object › Path › Smooth opens it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", + ), + ( + "ui.simplifyMoreOptions", + "More Options", + "", + "{} the Simplify dialog (dialog `simplify`: tolerance (pt), angleThreshold, straightLines, showOriginal, retain: ticked, OK makes Object › Path › Simplify open this dialog with these fields next time) takes the open Simplify slider's place, at the slider's tolerance → {tolerance}; an error when the slider isn't open", + ), + ( + "ui.dialogReset", + "Reset", + "", + "{} the open dialog's Reset, which its Cancel button reads while Alt (Option on macOS) is held (Flare Tool Options, dialog `flareOptions`): its fields go back to their defaults and the dialog stays open, so OK applies them → the dialog; an error when no dialog is open or the open one has no Reset", + ), + ( + "ui.justification", + "Justification…", + "", + "{} open Justification (the Paragraph panel menu's Justification…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, with Preview (dialog `justification`: wordMin, wordDesired, wordMax 0..1000, letterMin, letterDesired, letterMax -100..500, glyphMin, glyphDesired, glyphMax 50..200 (percent), autoLeading 0..500 (percent), singleWord (fullJustify|alignLeft|alignCenter|alignRight), preview): OK runs text.setFormat with the values that changed, as one undo step, and is disabled while a row's Minimum exceeds its Desired or Desired its Maximum; an error with no type selected", + ), + ( + "ui.hyphenation", + "Hyphenation…", + "", + "{} open Hyphenation (the Paragraph panel menu's Hyphenation…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows (dialog `hyphenation`: hyphenate, wordsLongerThan 1..25, afterFirst 1..15, beforeLast 1..15, hyphenLimit 0..25 (0: no limit), hyphenationZone (pt), hyphenateCapitalized): OK runs text.setFormat with the values that changed, as one undo step; an error with no type selected", + ), + ( + "ui.tateChuYokoSettings", + "Tate-chu-yoko Settings…", + "", + "{} open Tate-chu-yoko Settings (the Character panel menu's Tate-chu-yoko Settings…, with Show East Asian Options) on the characters the Character panel acts on, filled in with the values they share (dialog `tateChuYokoSettings`: tcyUpDown, tcyLeftRight (pt, -1296..1296)): OK runs text.setFormat (text.setRangeStyle while the Type tool edits text) with the values that changed, as one undo step; an error with no type selected", + ), + ( + "ui.colorGuideLimit", + "Limit Color Guide to Library", + "", + "{library: swatch library id or name (see swatch.library.list) | \"document\" (the document's swatches) | \"\" or null (no limit)} limit the Color Guide panel's colours to that library: every harmony colour and variation snaps to its nearest colour, as color.harmony {limitTo} answers (ui.inspect: ui.color_guide_limit) → {limitTo, name}", + ), + ( + "ui.colorPanel", + "Color Panel Options", + "", + "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences; RGB, HSB, Web Safe RGB and hex values use the working RGB space (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", + ), + ( + "ui.savePdfDialog", + "Save PDF Dialog", + "", + "{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)", + ), + ( + "file.exportAs", + "Export As…", + "", + "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}", + ), + ( + "ui.fileInfoDialog", + "File Info Dialog", + "", + "{} open File Info (dialog `fileInfo`: the file.info fields, plus __keyword, keywords typed but not added yet); OK runs file.info. The File Info… menu item opens it too", + ), + ( + "ui.rasterEffectsSettingsDialog", + "Document Raster Effects Settings Dialog", + "", + "{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too", + ), + ( + "ui.effectGalleryDialog", + "Effect Gallery Dialog", + "", + "{} open Effect Gallery (dialog `effectGallery`: browse raster filters, build a stack, OK applies it as separate effects in one undo step). Effect → Effect Gallery… opens it too", + ), + ( + "ui.pdfPresetsDialog", + "PDF Presets…", + "", + "{selected?: preset name} open Edit → PDF Presets (dialog `pdfPresets`, field `selected`): the built-in presets (read-only) and the saved ones, with the selected one's description and settings. New… and Edit… open the preset editor (ui.pdfPresetDialog); Delete, Import… and Export… run pdf.preset.delete / import / export", + ), + ( + "ui.pdfPresetDialog", + "PDF Preset", + "", + "{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets", + ), ("file.openRecent11", "Open Recent File 11", "", "{}"), ("file.openRecent12", "Open Recent File 12", "", "{}"), ("file.openRecent13", "Open Recent File 13", "", "{}"), @@ -382,74 +1259,414 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("file.openRecent29", "Open Recent File 29", "", "{}"), ("file.openRecent30", "Open Recent File 30", "", "{}"), ("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"), - ("file.cancelExport", "Cancel Export", "", "{} stop every export running in the background (Background Export): an export stops between artboards and writes no file once cancelled → {cancelled: n}"), - ("file.exportInBackground", "Continue in Background", "", "{} close the export progress dialog; the exports go on, shown in the status bar → {hidden: n}"), - ("file.cancelOpen", "Cancel Opening", "", "{} stop opening every file being read in the background (its progress window's Cancel): the files don't open → {cancelled: n}"), - ("file.openInBackground", "Continue in Background", "", "{} close the progress window of the files being opened; they go on being read, shown in the status bar → {hidden: n}"), - ("ui.dxfOptionsDialog", "DXF Options Dialog", "", "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last, a dxfPreset's options under them; the dxfPreset field shows cnc while every field it sets matches, else custom); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too"), - ("links.editOriginal", "Edit Original", "", "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads, and not over the control channel or MCP); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update"), - ("links.reveal", "Show in Folder", "", "{id?} show the file of linked image `id` (default: the selected linked image) in the system's file manager → {path}"), - ("ui.placementOptionsDialog", "Placement Options…", "", "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions"), - ("ui.relinkDialog", "Relink…", "", "{ids?: [image or placed document ids]} open Relink for `ids` (default: the selected ones; dialog `relink`: ids, links ([{name, ids}], one row per linked file), scope (Replace: each|selected (default)|all|missingInFolder, see links.relink), path (picked when empty), paths (with each: the files chosen so far, one per row of links)); OK runs links.relink with them, one undo step, except with each: OK takes path, or picks a file, for the next row of links, and the last row's OK relinks them all. An error when nothing is given or selected"), - ("ui.findLinksInFolder", "Find Linked Files in Folder…", "", "{folder?, ids?: [image or placed document ids]} the Links panel's Find in Folder…: asks for a folder (or takes folder), then opens Find Linked Files (dialog `findLinks`) looking in it and its subfolders for the files of linked objects `ids` (default: the selected linked images and placed documents, else every missing file; links.findFiles). An error when there is nothing to look for. Disabled where there is no folder picker (the web)"), - ("ui.packageDialog", "Package…", "Cmd+Alt+Shift+P", "{} open Package for the saved document (dialog `package`: folder, name, copyLinks, linksFolder, relink, copyFonts, report; a document never saved asks to Save As first); OK saves unsaved changes, runs file.package (the web downloads the zip) and offers file.showPackage"), - ("ui.action.rename", "Rename Action", "", "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, or an empty name, is an error and renames nothing → {name}"), - ("ui.action.stepsIncluded", "Include Action Steps", "", "{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing → {set, action?, step?, included}"), - ("ui.action.new", "New Action", "", "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \\\"\\\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, or a key or color that is not one of these, it is an error and nothing is added → {set, action}"), - ("ui.action.options", "Action Options", "", "{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \\\"\\\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, or a key or color that is not one of these is an error and changes nothing → {name, key, color}"), - ("ui.action.play", "Play Action", "", "{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. An action with a stop, at any speed, plays up to the stop and waits until ui.action.continue or ui.action.stop; at Accelerated the steps between stops are one undoable batch each → {results, pending, atStop?: true when it already waits}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays"), - ("ui.action.playbackOptions", "Playback Options", "", "{speed?: accelerated|step|pause, seconds?: 1-60} set how the Actions panel plays actions: accelerated (the default) plays every step at once, step plays one step at a time and draws the window after each, and pause waits seconds (a whole number, 1 to 60, VectorCraft's own range) between steps. A param left out keeps its value; the setting is kept with the UI preferences. Any other speed or seconds is an error and changes nothing. The panel menu's Playback Options… (dialog `actionPlayback` with speed and seconds) runs this on OK → {speed, seconds}"), - ("ui.action.record", "Begin Recording", "", "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set or an existing recording is an error and changes nothing → {set, name}"), - ("ui.action.stop", "Stop Playing/Recording", "", "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. With nothing recording or playing it changes nothing → {stopped}"), - ("ui.action.insertStop", "Insert Stop", "", "{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, or no action and no recording is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}"), - ("ui.action.continue", "Continue Action", "", "{} play on from the stop the Actions panel's playing action waits at, on the document active now, and close the stop's message box (dialog `actionStopMessage`): the next step runs at once, or at Accelerated the steps up to the next stop as one undo step. The panel's Play Current Selection runs this while an action waits. With no action waiting at a stop it is an error → {results, pending, atStop?}"), - ("ui.action.deleteStep", "Delete Action Step", "", "{set: index, action: index, step: index} delete one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step). The steps after it keep whether they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error and changes nothing. Undo does not bring the step back. The panel's Delete Selection runs this for the selected step → {set, action, step}"), - ("ui.batchDialog", "Batch…", "", "{set?: index, action?: index} open Batch (dialog `actionBatch`) for the action at set/action (default: the first action of the first set); OK runs action.batch with the dialog's source, destination and overrides"), - ("ui.actionSet.save", "Save Actions…", "", "{set?: index, id?, path?} write the Actions panel's set at set (0 is the top set; default: the current set) with its actions to an action set file (.vcactions) at path, else a picked file named after the set (the web downloads it). id holds the panel's own set id, which its menu passes: the set with this id is saved wherever it is now, and set is not read. A set over 8 MiB as a file, with more than 1,000 actions or with an action of more than 10,000 steps is not written; the panel menu's Save Actions… → {path}"), - ("ui.actionSet.load", "Load Actions…", "", "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \\\"not an action set file\\\""), - ("ui.actionSet.delete", "Delete Action Sets", "", "{sets?: [index], ids?: [id]} delete the Actions panel's sets at these indexes (0 is the top set), with their actions (default: the current set). ids holds the panel's own set ids, which its question passes: the sets with these ids are deleted wherever they are now, sets is not read, and when one of them no longer exists nothing is deleted. The panel's Delete Selection on selected sets (Shift-click adds a range) asks first, and OK runs this. A recording into a deleted set stops and keeps nothing. An index past the last set is an error → {deleted: n}"), - ("ui.actionSet.duplicate", "Duplicate Action or Action Set", "", "{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \\\" copy\\\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action is an error → {set, action?}"), + ( + "file.cancelExport", + "Cancel Export", + "", + "{} stop every export running in the background (Background Export): an export stops between artboards and writes no file once cancelled → {cancelled: n}", + ), + ( + "file.exportInBackground", + "Continue in Background", + "", + "{} close the export progress dialog; the exports go on, shown in the status bar → {hidden: n}", + ), + ( + "file.cancelOpen", + "Cancel Opening", + "", + "{} stop opening every file being read in the background (its progress window's Cancel): the files don't open → {cancelled: n}", + ), + ( + "file.openInBackground", + "Continue in Background", + "", + "{} close the progress window of the files being opened; they go on being read, shown in the status bar → {hidden: n}", + ), + ( + "ui.dxfOptionsDialog", + "DXF Options Dialog", + "", + "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last, a dxfPreset's options under them; the dxfPreset field shows cnc while every field it sets matches, else custom); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", + ), + ( + "links.editOriginal", + "Edit Original", + "", + "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads, and not over the control channel or MCP); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update", + ), + ( + "links.reveal", + "Show in Folder", + "", + "{id?} show the file of linked image `id` (default: the selected linked image) in the system's file manager → {path}", + ), + ( + "ui.placementOptionsDialog", + "Placement Options…", + "", + "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions", + ), + ( + "ui.relinkDialog", + "Relink…", + "", + "{ids?: [image or placed document ids]} open Relink for `ids` (default: the selected ones; dialog `relink`: ids, links ([{name, ids}], one row per linked file), scope (Replace: each|selected (default)|all|missingInFolder, see links.relink), path (picked when empty), paths (with each: the files chosen so far, one per row of links)); OK runs links.relink with them, one undo step, except with each: OK takes path, or picks a file, for the next row of links, and the last row's OK relinks them all. An error when nothing is given or selected", + ), + ( + "ui.findLinksInFolder", + "Find Linked Files in Folder…", + "", + "{folder?, ids?: [image or placed document ids]} the Links panel's Find in Folder…: asks for a folder (or takes folder), then opens Find Linked Files (dialog `findLinks`) looking in it and its subfolders for the files of linked objects `ids` (default: the selected linked images and placed documents, else every missing file; links.findFiles). An error when there is nothing to look for. Disabled where there is no folder picker (the web)", + ), + ( + "ui.packageDialog", + "Package…", + "Cmd+Alt+Shift+P", + "{} open Package for the saved document (dialog `package`: folder, name, copyLinks, linksFolder, relink, copyFonts, report; a document never saved asks to Save As first); OK saves unsaved changes, runs file.package (the web downloads the zip) and offers file.showPackage", + ), + ( + "ui.action.rename", + "Rename Action", + "", + "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, or an empty name, is an error and renames nothing → {name}", + ), + ( + "ui.action.stepsIncluded", + "Include Action Steps", + "", + "{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing → {set, action?, step?, included}", + ), + ( + "ui.action.new", + "New Action", + "", + "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, or a key or color that is not one of these, it is an error and nothing is added → {set, action}", + ), + ( + "ui.action.options", + "Action Options", + "", + "{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \"\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, or a key or color that is not one of these is an error and changes nothing → {name, key, color}", + ), + ( + "ui.action.play", + "Play Action", + "", + "{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. An action with a stop, at any speed, plays up to the stop and waits until ui.action.continue or ui.action.stop; at Accelerated the steps between stops are one undoable batch each → {results, pending, atStop?: true when it already waits}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays", + ), + ( + "ui.action.playbackOptions", + "Playback Options", + "", + "{speed?: accelerated|step|pause, seconds?: 1-60} set how the Actions panel plays actions: accelerated (the default) plays every step at once, step plays one step at a time and draws the window after each, and pause waits seconds (a whole number, 1 to 60, VectorCraft's own range) between steps. A param left out keeps its value; the setting is kept with the UI preferences. Any other speed or seconds is an error and changes nothing. The panel menu's Playback Options… (dialog `actionPlayback` with speed and seconds) runs this on OK → {speed, seconds}", + ), + ( + "ui.action.record", + "Begin Recording", + "", + "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set or an existing recording is an error and changes nothing → {set, name}", + ), + ( + "ui.action.stop", + "Stop Playing/Recording", + "", + "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. With nothing recording or playing it changes nothing → {stopped}", + ), + ( + "ui.action.insertStop", + "Insert Stop", + "", + "{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, or no action and no recording is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", + ), + ( + "ui.action.continue", + "Continue Action", + "", + "{} play on from the stop the Actions panel's playing action waits at, on the document active now, and close the stop's message box (dialog `actionStopMessage`): the next step runs at once, or at Accelerated the steps up to the next stop as one undo step. The panel's Play Current Selection runs this while an action waits. With no action waiting at a stop it is an error → {results, pending, atStop?}", + ), + ( + "ui.action.deleteStep", + "Delete Action Step", + "", + "{set: index, action: index, step: index} delete one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step). The steps after it keep whether they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error and changes nothing. Undo does not bring the step back. The panel's Delete Selection runs this for the selected step → {set, action, step}", + ), + ( + "ui.batchDialog", + "Batch…", + "", + "{set?: index, action?: index} open Batch (dialog `actionBatch`) for the action at set/action (default: the first action of the first set); OK runs action.batch with the dialog's source, destination and overrides", + ), + ( + "ui.actionSet.save", + "Save Actions…", + "", + "{set?: index, id?, path?} write the Actions panel's set at set (0 is the top set; default: the current set) with its actions to an action set file (.vcactions) at path, else a picked file named after the set (the web downloads it). id holds the panel's own set id, which its menu passes: the set with this id is saved wherever it is now, and set is not read. A set over 8 MiB as a file, with more than 1,000 actions or with an action of more than 10,000 steps is not written; the panel menu's Save Actions… → {path}", + ), + ( + "ui.actionSet.load", + "Load Actions…", + "", + "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", + ), + ( + "ui.actionSet.delete", + "Delete Action Sets", + "", + "{sets?: [index], ids?: [id]} delete the Actions panel's sets at these indexes (0 is the top set), with their actions (default: the current set). ids holds the panel's own set ids, which its question passes: the sets with these ids are deleted wherever they are now, sets is not read, and when one of them no longer exists nothing is deleted. The panel's Delete Selection on selected sets (Shift-click adds a range) asks first, and OK runs this. A recording into a deleted set stops and keeps nothing. An index past the last set is an error → {deleted: n}", + ), + ( + "ui.actionSet.duplicate", + "Duplicate Action or Action Set", + "", + "{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \" copy\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action is an error → {set, action?}", + ), ("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"), - ("docInfo.save", "Save Document Info…", "", "{path?, selectionOnly?, category?} write Document Info's text report (document.info {format: \\\"text\\\"}) to path, else a picked file (the web downloads it); with category (a document.info category id) only that category, else every category. The panel menu's Save… passes the category the panel shows and its Selection Only → {path}"), - ("ui.epsOptionsDialog", "EPS Options Dialog", "", "{path?, useArtboards?, range?, …document.exportEps options} open EPS Options (dialog `epsOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → EPS opens it too"), - ("file.saveForWeb", "Save for Web (Legacy)…", "Cmd+Alt+Shift+S", "{} open Save for Web (dialog `saveForWeb`: fields = the webExport.settings settings, `preset` loads a preset, __view: original|optimized|2up|4up, __zoom, __kbps; Save… remembers the settings and writes the files, Done only remembers them); with settings = document.exportForWeb, written to path, else a picked file (the web downloads the files) → {path, files, bytes}"), - ("file.saveForWeb.browser", "Preview in Browser", "", "{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}"), - ("file.exportSelection", "Export Selection…", "", "{multiple?: false} collect the selected objects as assets (assets.add: one of them all, as one image; true: one per object) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead"), - ("css.copy", "Copy Selected Style", "", "{scope?: selection|all (default selection), …css.selection options} copy the CSS of the selection (css.selection; all: css.generate) to the clipboard (CSS Properties panel) → {css, rules: n}"), - ("css.exportFile", "Export CSS…", "", "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}"), - ("file.print", "Print…", "Cmd+P", "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {preset? (a print preset's name: its settings instead of the document's), settings? (over the document's or the preset's), printer? (print.printers name; \\\"\\\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way"), - ("print.printers", "Printers", "", "{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}"), + ( + "docInfo.save", + "Save Document Info…", + "", + "{path?, selectionOnly?, category?} write Document Info's text report (document.info {format: \"text\"}) to path, else a picked file (the web downloads it); with category (a document.info category id) only that category, else every category. The panel menu's Save… passes the category the panel shows and its Selection Only → {path}", + ), + ( + "ui.epsOptionsDialog", + "EPS Options Dialog", + "", + "{path?, useArtboards?, range?, …document.exportEps options} open EPS Options (dialog `epsOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → EPS opens it too", + ), + ( + "file.saveForWeb", + "Save for Web (Legacy)…", + "Cmd+Alt+Shift+S", + "{} open Save for Web (dialog `saveForWeb`: fields = the webExport.settings settings, `preset` loads a preset, __view: original|optimized|2up|4up, __zoom, __kbps; Save… remembers the settings and writes the files, Done only remembers them); with settings = document.exportForWeb, written to path, else a picked file (the web downloads the files) → {path, files, bytes}", + ), + ( + "file.saveForWeb.browser", + "Preview in Browser", + "", + "{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}", + ), + ( + "file.exportSelection", + "Export Selection…", + "", + "{multiple?: false} collect the selected objects as assets (assets.add: one of them all, as one image; true: one per object) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead", + ), + ( + "css.copy", + "Copy Selected Style", + "", + "{scope?: selection|all (default selection), …css.selection options} copy the CSS of the selection (css.selection; all: css.generate) to the clipboard (CSS Properties panel) → {css, rules: n}", + ), + ( + "css.exportFile", + "Export CSS…", + "", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", + ), + ( + "file.print", + "Print…", + "Cmd+P", + "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {preset? (a print preset's name: its settings instead of the document's), settings? (over the document's or the preset's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", + ), + ( + "print.printers", + "Printers", + "", + "{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}", + ), ("print.printerSetup", "Printer Setup…", "", "{printer?} open the system's settings of the printer (the Print dialog's Setup…; desktop)"), - ("ui.printPresetsDialog", "Print Presets…", "", "{selected?: preset name} open Edit → Print Presets (dialog `printPresets`, field `selected`): [Default] (protected) and the saved presets, with how the selected one differs from [Default]. New… and Edit… open the preset editor (ui.printPresetDialog); Delete, Import… and Export… run print.presets.delete / import / export"), - ("ui.printPresetDialog", "Print Preset", "", "{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets"), - ("plugin.dialog", "Plug-in…", "", "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)"), - ("ui.installPlugin", "Install Plug-in…", "", "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \\\"pluginInstall\\\"} while it asks about permissions or a heavy budget | {pending: \\\"confirm\\\"} while it asks only about replacing"), - ("plugin.cancelRuns", "Cancel Plug-in Run", "", "{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}"), - ("plugin.hideRuns", "Continue in Background", "", "{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}"), - ("imageTrace.cancelRuns", "Cancel Image Trace", "", "{} stop every Image Trace running in the background (its progress window's Cancel): it stops at its next colour and changes nothing → {cancelled: n}"), - ("imageTrace.hideRuns", "Continue in Background", "", "{} close the progress window of the Image Traces running in the background; they go on, shown in the status bar → {hidden: n}"), - ("ui.runScript", "Other Script…", "Cmd+F12", "{path? | source?, name?} run a .js or .jsx file (script.run), after the warning unless Preferences › General › Show External Script Warning is off (dialog `scriptRun`: `name`, `script`, `dontShow`); without a file pick one (the web opens the browser's file picker)"), - ("ui.checkScript", "Check Script…", "", "{path? | data? | dataBase64?, name?, includes?: true with a path} check a script written for the reference app without running it (script.check; with a path, the files its #include lines name are checked too) and show the report (dialog `scriptCheck`: `report`, script.check's result; Copy Report copies it as JSON); without a file pick one (the web opens the browser's file picker; File › Open of a .jsx, .jsxinc or .jsxbin file shows the report too)"), - ("ui.perspectiveGridDialog", "Define Perspective Grid…", "", "{} open View › Perspective Grid › Define Grid (dialog `perspectiveGrid`, prefilled from the grid: the fields of perspective.grid.define); OK runs perspective.grid.define"), - ("ui.perspectivePresetsDialog", "Perspective Grid Presets…", "", "{selected?} open Edit › Perspective Grid Presets (dialog `perspectiveGridPresets`, field `selected`): New… and Edit… open the preset editor (dialog `perspectiveGrid` with `__mode` edit), whose OK runs perspective.presets.save and comes back; Delete, Import… and Export… run perspective.presets.*"), - ("ui.savePerspectivePreset", "Save Grid as Preset…", "", "{} open View › Perspective Grid › Save Grid as Preset (dialog `perspectiveGrid` with `__mode` save, `name` a new preset name): OK runs perspective.presets.save with the fields"), - ("ui.perspectiveUserPreset1.1", "One Point Perspective Preset 1", "", "{} apply the 1. saved 1-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset1.2", "One Point Perspective Preset 2", "", "{} apply the 2. saved 1-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset1.3", "One Point Perspective Preset 3", "", "{} apply the 3. saved 1-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset1.4", "One Point Perspective Preset 4", "", "{} apply the 4. saved 1-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset1.5", "One Point Perspective Preset 5", "", "{} apply the 5. saved 1-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset2.1", "Two Point Perspective Preset 1", "", "{} apply the 1. saved 2-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset2.2", "Two Point Perspective Preset 2", "", "{} apply the 2. saved 2-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset2.3", "Two Point Perspective Preset 3", "", "{} apply the 3. saved 2-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset2.4", "Two Point Perspective Preset 4", "", "{} apply the 4. saved 2-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset2.5", "Two Point Perspective Preset 5", "", "{} apply the 5. saved 2-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset3.1", "Three Point Perspective Preset 1", "", "{} apply the 1. saved 3-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset3.2", "Three Point Perspective Preset 2", "", "{} apply the 2. saved 3-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset3.3", "Three Point Perspective Preset 3", "", "{} apply the 3. saved 3-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset3.4", "Three Point Perspective Preset 4", "", "{} apply the 4. saved 3-point perspective grid preset (perspective.grid.preset)"), - ("ui.perspectiveUserPreset3.5", "Three Point Perspective Preset 5", "", "{} apply the 5. saved 3-point perspective grid preset (perspective.grid.preset)"), - ("ui.blendOptions", "Blend Options…", "", "{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with"), - ("ui.livePaintGapOptions", "Gap Options…", "", "{} open Gap Options (Object › Live Paint › Gap Options…; dialog `livePaintGapOptions`: detect, size small|medium|large|custom, custom (pt), color (#rrggbb or a layer color's name), closeGaps, preview) on the selected Live Paint groups, filled in from the first; with preview on, the options apply live and the gaps found draw on the canvas in the preview color: OK runs livePaint.gapOptions as one undo step; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing, and with preview on a #rrggbbaa color is not previewed: the groups keep their own color and the gaps draw in black"), - ("ui.perspectivePlane", "Perspective Plane Options…", "", "{plane?: left|right|ground (default: the active plane)} open the plane's options, as double-clicking its plane widget does (dialog `perspectivePlane`: location (pt along the plane's normal), objects: none|move|copy): OK runs perspective.plane.move"), + ( + "ui.printPresetsDialog", + "Print Presets…", + "", + "{selected?: preset name} open Edit → Print Presets (dialog `printPresets`, field `selected`): [Default] (protected) and the saved presets, with how the selected one differs from [Default]. New… and Edit… open the preset editor (ui.printPresetDialog); Delete, Import… and Export… run print.presets.delete / import / export", + ), + ( + "ui.printPresetDialog", + "Print Preset", + "", + "{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets", + ), + ( + "plugin.dialog", + "Plug-in…", + "", + "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)", + ), + ( + "ui.installPlugin", + "Install Plug-in…", + "", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing", + ), + ( + "plugin.cancelRuns", + "Cancel Plug-in Run", + "", + "{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}", + ), + ( + "plugin.hideRuns", + "Continue in Background", + "", + "{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}", + ), + ( + "imageTrace.cancelRuns", + "Cancel Image Trace", + "", + "{} stop every Image Trace running in the background (its progress window's Cancel): it stops at its next colour and changes nothing → {cancelled: n}", + ), + ( + "imageTrace.hideRuns", + "Continue in Background", + "", + "{} close the progress window of the Image Traces running in the background; they go on, shown in the status bar → {hidden: n}", + ), + ( + "ui.runScript", + "Other Script…", + "Cmd+F12", + "{path? | source?, name?} run a .js or .jsx file (script.run), after the warning unless Preferences › General › Show External Script Warning is off (dialog `scriptRun`: `name`, `script`, `dontShow`); without a file pick one (the web opens the browser's file picker)", + ), + ( + "ui.runInstalledScript", + "Run Script", + "", + "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (the script is checked again, then script.run runs it by its path, so its #include lines and $.evalFile read beside it), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", + ), + ( + "ui.checkScript", + "Check Script…", + "", + "{path? | data? | dataBase64?, name?, includes?: true with a path} check a script written for the reference app without running it (script.check; with a path, the files its #include lines name are checked too) and show the report (dialog `scriptCheck`: `report`, script.check's result; Copy Report copies it as JSON); without a file pick one (the web opens the browser's file picker; File › Open of a .jsx, .jsxinc or .jsxbin file shows the report too)", + ), + ( + "ui.perspectiveGridDialog", + "Define Perspective Grid…", + "", + "{} open View › Perspective Grid › Define Grid (dialog `perspectiveGrid`, prefilled from the grid: the fields of perspective.grid.define); OK runs perspective.grid.define", + ), + ( + "ui.perspectivePresetsDialog", + "Perspective Grid Presets…", + "", + "{selected?} open Edit › Perspective Grid Presets (dialog `perspectiveGridPresets`, field `selected`): New… and Edit… open the preset editor (dialog `perspectiveGrid` with `__mode` edit), whose OK runs perspective.presets.save and comes back; Delete, Import… and Export… run perspective.presets.*", + ), + ( + "ui.savePerspectivePreset", + "Save Grid as Preset…", + "", + "{} open View › Perspective Grid › Save Grid as Preset (dialog `perspectiveGrid` with `__mode` save, `name` a new preset name): OK runs perspective.presets.save with the fields", + ), + ( + "ui.perspectiveUserPreset1.1", + "One Point Perspective Preset 1", + "", + "{} apply the 1. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.2", + "One Point Perspective Preset 2", + "", + "{} apply the 2. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.3", + "One Point Perspective Preset 3", + "", + "{} apply the 3. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.4", + "One Point Perspective Preset 4", + "", + "{} apply the 4. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.5", + "One Point Perspective Preset 5", + "", + "{} apply the 5. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.1", + "Two Point Perspective Preset 1", + "", + "{} apply the 1. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.2", + "Two Point Perspective Preset 2", + "", + "{} apply the 2. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.3", + "Two Point Perspective Preset 3", + "", + "{} apply the 3. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.4", + "Two Point Perspective Preset 4", + "", + "{} apply the 4. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.5", + "Two Point Perspective Preset 5", + "", + "{} apply the 5. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.1", + "Three Point Perspective Preset 1", + "", + "{} apply the 1. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.2", + "Three Point Perspective Preset 2", + "", + "{} apply the 2. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.3", + "Three Point Perspective Preset 3", + "", + "{} apply the 3. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.4", + "Three Point Perspective Preset 4", + "", + "{} apply the 4. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.5", + "Three Point Perspective Preset 5", + "", + "{} apply the 5. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.blendOptions", + "Blend Options…", + "", + "{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with", + ), + ( + "ui.livePaintGapOptions", + "Gap Options…", + "", + "{} open Gap Options (Object › Live Paint › Gap Options…; dialog `livePaintGapOptions`: detect, size small|medium|large|custom, custom (pt), color (#rrggbb or a layer color's name), closeGaps, preview) on the selected Live Paint groups, filled in from the first; with preview on, the options apply live and the gaps found draw on the canvas in the preview color: OK runs livePaint.gapOptions as one undo step; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing, and with preview on a #rrggbbaa color is not previewed: the groups keep their own color and the gaps draw in black", + ), + ( + "ui.perspectivePlane", + "Perspective Plane Options…", + "", + "{plane?: left|right|ground (default: the active plane)} open the plane's options, as double-clicking its plane widget does (dialog `perspectivePlane`: location (pt along the plane's normal), objects: none|move|copy): OK runs perspective.plane.move", + ), ]; - diff --git a/docs/mcp.md b/docs/mcp.md index 28a04d4496..6d6a2126e8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -19,6 +19,7 @@ only protocol messages. `command_list` / `list_commands` / `vectorcraft://commands` return the live catalogue for the backend in use: - **Headless:** engine and plug-in commands, plus the desktop menu catalogue (`UI_COMMANDS`) marked `"ui": true` so agents can discover ids such as `view.fitArtboard`, `file.newDialog` and `window.*`. Running a UI-only command that the headless host does not implement returns an error that points at `--connect`. + The catalogue is mirrored in `crates/mcp/src/ui_catalog.rs` (mcp does not depend on ui-egui). Regenerate with `cargo xtask ui-commands`; `cargo xtask ci` runs `ui-commands --check` so the mirror cannot drift. - **Remote:** the same lists, with each UI entry's `enabled` taken from the live app. Run UI commands with `command_run` / `run_command` or `invoke_menu`. Effect ids from `effect.list` / `apply_effect` / `vectorcraft://effect/{id}` (for example `warp.arc`, `blur.gaussian`, `threeD.revolve`, `adjust.hueSaturation`, `addon.path.dashes`, `addon.color.halftone`) are not command ids. Apply one with `effect.apply` or the `apply_effect` tool; do not pass an effect id to `command_run`. Path Effects keep the `addon.path.*` effect ids; the Path Tools add-on's *commands* are `addon.pathtools.*`. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 260b103870..c827a1ef74 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -15,6 +15,7 @@ mod pngsuite; mod sha256; mod stats; mod test262; +mod ui_commands; mod version; mod web_fonts; @@ -30,11 +31,12 @@ commands: cursors warn about Cursor values with no entry in crates/tools/src/cursors.toml (never fails) i18n-report list the strings each language's catalog lacks, shown in English (fails only on a broken scan; ci runs it too) + ui-commands regenerate crates/mcp/src/ui_catalog.rs from desktop UI_COMMANDS; --check fails on drift pdf-deps verify immutable shared PDF writer and patched Krilla sources layers enforce the crate dependency layering (plan/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates and the web app test the workspace tests (cargo nextest when installed, else cargo test), then the doctests - ci fmt --check, clippy -D warnings, test, doctests, no-bundled-fonts, pdf-deps, assets, brands, cursors (warnings only), layers, wasm (stops at first failure) + ci fmt --check, clippy -D warnings, test, doctests, no-bundled-fonts, pdf-deps, assets, brands, cursors (warnings only), ui-commands --check, layers, wasm (stops at first failure) corpus [--download] [--affinity] show where test corpora live; --download fetches PngSuite into corpus/pngsuite, --affinity the pinned public Affinity documents into corpus/affinity @@ -59,6 +61,7 @@ fn main() -> ExitCode { Some("brands") => brands::run(&root()), Some("cursors") => cursors::run(&root()), Some("i18n-report") => cmd_i18n_report(), + Some("ui-commands") => ui_commands::run(&root(), rest.contains(&"--check")), Some("layers") => cmd_layers(), Some("pdf-deps") => pdf_deps::run(&root()), Some("wasm") => cmd_wasm(), @@ -302,6 +305,7 @@ fn cmd_ci() -> Result<(), String> { ("brands", Box::new(|| brands::run(&root()))), ("cursors", Box::new(|| cursors::run(&root()))), ("i18n-report", Box::new(cmd_ci_i18n_report)), + ("ui-commands", Box::new(|| ui_commands::run(&root(), true))), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), ]; diff --git a/xtask/src/ui_commands.rs b/xtask/src/ui_commands.rs new file mode 100644 index 0000000000..bb8d8dfdd7 --- /dev/null +++ b/xtask/src/ui_commands.rs @@ -0,0 +1,466 @@ +//! `cargo xtask ui-commands`: regenerate or check the MCP mirror of desktop `UI_COMMANDS`. +//! +//! Source of truth: `crates/ui-egui/src/menus/ui_commands.rs`. +//! Mirror: `crates/mcp/src/ui_catalog.rs` (mcp must not depend on ui-egui). +//! +//! `cargo xtask ui-commands` writes the mirror. +//! `cargo xtask ui-commands --check` fails when the committed file drifts. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub const SOURCE: &str = "crates/ui-egui/src/menus/ui_commands.rs"; +pub const MIRROR: &str = "crates/mcp/src/ui_catalog.rs"; +const UI_EGUI_SRC: &str = "crates/ui-egui/src"; + +pub fn run(root: &Path, check_only: bool) -> Result<(), String> { + let expected = generate(root)?; + let mirror_path = root.join(MIRROR); + if check_only { + let actual = std::fs::read_to_string(&mirror_path).map_err(|e| format!("{MIRROR}: {e}"))?; + if actual == expected { + println!("ui-commands: {MIRROR} matches {SOURCE}"); + return Ok(()); + } + return Err(format!("{MIRROR} is out of date with {SOURCE}. Run `cargo xtask ui-commands` and commit the result.")); + } + if let Some(parent) = mirror_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{parent:?}: {e}"))?; + } + std::fs::write(&mirror_path, expected).map_err(|e| format!("{MIRROR}: {e}"))?; + println!("ui-commands: wrote {MIRROR}"); + Ok(()) +} + +/// Build the mirror file contents from the desktop table (rustfmt'd, same as `cargo fmt`). +pub fn generate(root: &Path) -> Result { + let source = std::fs::read_to_string(root.join(SOURCE)).map_err(|e| format!("{SOURCE}: {e}"))?; + let entries = parse_ui_commands(&source)?; + let resolved = resolve_entries(root, &entries)?; + rustfmt_source(root, &render_mirror(&resolved)) +} + +/// Format `src` with the workspace `rustfmt` (edition 2024, same as `cargo fmt -p vectorcraft-mcp`). +fn rustfmt_source(root: &Path, src: &str) -> Result { + let dir = std::env::temp_dir(); + let path = dir.join(format!("vectorcraft-ui-catalog-{}.rs", std::process::id())); + std::fs::write(&path, src).map_err(|e| format!("temp mirror: {e}"))?; + let config = root.join("rustfmt.toml"); + let status = + Command::new("rustfmt").args(["--edition", "2024", "--config-path"]).arg(&config).arg(&path).status().map_err(|e| format!("rustfmt: {e}"))?; + let result = if status.success() { + std::fs::read_to_string(&path).map_err(|e| format!("temp mirror read: {e}")) + } else { + Err(format!("rustfmt failed with {status}")) + }; + let _ = std::fs::remove_file(&path); + result +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Entry { + id: IdExpr, + label: String, + shortcut: String, + params: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum IdExpr { + Lit(String), + Path(String), +} + +fn parse_ui_commands(src: &str) -> Result, String> { + let start = src.find("pub const UI_COMMANDS").ok_or_else(|| format!("{SOURCE}: no `pub const UI_COMMANDS`"))?; + let after = &src[start..]; + // Prefer `= &[` so the type `&[(&str, …)]` is not mistaken for the value. + let bracket = after.find("= &[").ok_or_else(|| format!("{SOURCE}: UI_COMMANDS is not an `= &[...]`"))?; + let open_idx = start + bracket + 3; // index of '[' + let body_start = open_idx + 1; + let body_end = find_matching_bracket(src, open_idx).ok_or_else(|| format!("{SOURCE}: unclosed UI_COMMANDS array"))?; + let body = &src[body_start..body_end]; + let tuples = split_top_level_tuples(body)?; + let mut out = Vec::with_capacity(tuples.len()); + for t in tuples { + out.push(parse_tuple(t)?); + } + if out.is_empty() { + return Err(format!("{SOURCE}: UI_COMMANDS is empty")); + } + Ok(out) +} + +fn find_matching_bracket(src: &str, open_idx: usize) -> Option { + let bytes = src.as_bytes(); + if open_idx >= bytes.len() || bytes[open_idx] != b'[' { + return None; + } + let mut depth = 0usize; + let mut in_str = false; + let mut esc = false; + let mut i = open_idx; + while i < bytes.len() { + let c = bytes[i] as char; + if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + i += 1; + continue; + } + match c { + '"' => in_str = true, + '[' => depth += 1, + ']' => { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + i += 1; + } + None +} + +fn split_top_level_tuples(body: &str) -> Result, String> { + let mut out = Vec::new(); + let bytes = body.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + while i < bytes.len() && ((bytes[i] as char).is_whitespace() || bytes[i] == b',') { + i += 1; + } + if i >= bytes.len() { + break; + } + if bytes[i] != b'(' { + return Err(format!("{SOURCE}: expected `(` in UI_COMMANDS, found {:?}", body[i..].chars().take(20).collect::())); + } + let start = i; + let mut depth = 0usize; + let mut in_str = false; + let mut esc = false; + while i < bytes.len() { + let c = bytes[i] as char; + if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + i += 1; + continue; + } + match c { + '"' => in_str = true, + '(' => depth += 1, + ')' => { + depth -= 1; + i += 1; + if depth == 0 { + out.push(&body[start..i]); + break; + } + continue; + } + _ => {} + } + i += 1; + } + if depth != 0 { + return Err(format!("{SOURCE}: unclosed tuple in UI_COMMANDS")); + } + } + Ok(out) +} + +fn parse_tuple(tup: &str) -> Result { + let inner = tup.strip_prefix('(').and_then(|s| s.strip_suffix(')')).ok_or_else(|| format!("{SOURCE}: bad tuple {tup:?}"))?; + let fields = split_fields(inner)?; + if fields.len() != 4 { + return Err(format!("{SOURCE}: tuple has {} fields, want 4: {tup}", fields.len())); + } + let id = parse_id(fields[0].trim())?; + let label = parse_string_lit(fields[1].trim())?; + let shortcut = parse_string_lit(fields[2].trim())?; + let params = parse_string_lit(fields[3].trim())?; + Ok(Entry { id, label, shortcut, params }) +} + +fn split_fields(inner: &str) -> Result, String> { + let mut out = Vec::new(); + let bytes = inner.as_bytes(); + let mut start = 0usize; + let mut i = 0usize; + let mut depth = 0isize; + let mut in_str = false; + let mut esc = false; + while i < bytes.len() { + let c = bytes[i] as char; + if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + i += 1; + continue; + } + match c { + '"' => in_str = true, + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + ',' if depth == 0 => { + out.push(inner[start..i].trim()); + start = i + 1; + } + _ => {} + } + i += 1; + } + let last = inner[start..].trim(); + if !last.is_empty() { + out.push(last); + } + Ok(out) +} + +fn parse_id(field: &str) -> Result { + if field.starts_with('"') { + return Ok(IdExpr::Lit(parse_string_lit(field)?)); + } + if field.starts_with("crate::") { + return Ok(IdExpr::Path(field.to_string())); + } + Err(format!("{SOURCE}: unsupported id expression `{field}`")) +} + +fn parse_string_lit(field: &str) -> Result { + let s = field.trim(); + if !s.starts_with('"') { + return Err(format!("{SOURCE}: expected string literal, got `{s}`")); + } + let mut out = String::new(); + let mut chars = s[1..].chars(); + while let Some(c) = chars.next() { + if c == '\\' { + let Some(n) = chars.next() else { + return Err(format!("{SOURCE}: trailing escape in {s}")); + }; + match n { + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + '\\' | '"' | '\'' => out.push(n), + other => return Err(format!("{SOURCE}: unknown escape `\\{other}` in {s}")), + } + } else if c == '"' { + return Ok(out); + } else { + out.push(c); + } + } + Err(format!("{SOURCE}: unclosed string {s}")) +} + +fn resolve_entries(root: &Path, entries: &[Entry]) -> Result, String> { + let mut out = Vec::with_capacity(entries.len()); + for e in entries { + let id = match &e.id { + IdExpr::Lit(s) => s.clone(), + IdExpr::Path(path) => resolve_const_path(root, path)?, + }; + out.push((id, e.label.clone(), e.shortcut.clone(), e.params.clone())); + } + Ok(out) +} + +fn resolve_const_path(root: &Path, path: &str) -> Result { + let trimmed = path.strip_prefix("crate::").ok_or_else(|| format!("{SOURCE}: path `{path}` does not start with crate::"))?; + let mut parts: Vec<&str> = trimmed.split("::").collect(); + let const_name = parts.pop().ok_or_else(|| format!("{SOURCE}: empty path `{path}`"))?; + resolve_in_module(root, &parts, const_name, path) +} + +fn resolve_in_module(root: &Path, module_parts: &[&str], const_name: &str, original: &str) -> Result { + let file = module_file(root, module_parts).ok_or_else(|| format!("{SOURCE}: no file for `{original}`"))?; + let src = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; + if let Some(v) = find_str_const(&src, const_name) { + return Ok(v); + } + // Follow `pub use path::{..., CONST, ...}` / `pub use path::CONST`. + for target in reexport_targets(&src, const_name) { + let mut next = module_parts.to_vec(); + if target.first() == Some(&"super") { + next.pop(); + next.extend(target.iter().skip(1).copied()); + } else if target.first() == Some(&"crate") { + next = target.iter().skip(1).copied().collect(); + } else { + next.extend(target); + } + if let Ok(v) = resolve_in_module(root, &next, const_name, original) { + return Ok(v); + } + } + Err(format!("{SOURCE}: could not resolve `{original}` (`pub const {const_name}: &str` missing under {})", file.display())) +} + +fn module_file(root: &Path, parts: &[&str]) -> Option { + let base = root.join(UI_EGUI_SRC); + if parts.is_empty() { + return Some(base.join("lib.rs")); + } + let mut dir = base.clone(); + for (i, p) in parts.iter().enumerate() { + let last = i + 1 == parts.len(); + if last { + let rs = dir.join(format!("{p}.rs")); + if rs.is_file() { + return Some(rs); + } + let mod_rs = dir.join(p).join("mod.rs"); + if mod_rs.is_file() { + return Some(mod_rs); + } + return None; + } + dir = dir.join(p); + } + None +} + +fn find_str_const(src: &str, name: &str) -> Option { + // `pub const NAME: &str = "...";` (allow whitespace) + let needle = format!("pub const {name}:"); + let mut search = src; + while let Some(idx) = search.find(&needle) { + let after = &search[idx + needle.len()..]; + let after = after.trim_start(); + if !after.starts_with("&str") { + search = &search[idx + needle.len()..]; + continue; + } + let after = after["&str".len()..].trim_start(); + if !after.starts_with('=') { + search = &search[idx + needle.len()..]; + continue; + } + let after = after[1..].trim_start(); + return parse_string_lit(after).ok(); + } + None +} + +fn reexport_targets<'a>(src: &'a str, const_name: &str) -> Vec> { + let mut out = Vec::new(); + for line in src.lines() { + let t = line.trim(); + if !t.starts_with("pub use ") { + continue; + } + let rest = t.trim_start_matches("pub use ").trim_end_matches(';').trim(); + if let Some((path, braced)) = rest.split_once("::{") { + let names = braced.trim_end_matches('}'); + let hit = names.split(',').map(str::trim).any(|n| n == const_name); + if hit { + out.push(path.split("::").collect()); + } + } else if let Some((path, name)) = rest.rsplit_once("::") { + if name.trim() == const_name { + out.push(path.split("::").collect()); + } + } + } + out +} + +fn render_mirror(rows: &[(String, String, String, String)]) -> String { + let mut out = String::new(); + out.push_str("//! Desktop UI command catalogue for headless MCP discovery (`ui: true`).\n"); + out.push_str("//! Generated from `crates/ui-egui/src/menus/ui_commands.rs` by `cargo xtask ui-commands`.\n"); + out.push_str("//! Do not edit by hand; re-run the xtask when that table changes.\n"); + out.push('\n'); + out.push_str("/// `(id, label, shortcut, params doc)` — same shape as the desktop `UI_COMMANDS` table.\n"); + out.push_str("pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[\n"); + for (id, label, shortcut, params) in rows { + out.push_str(" ("); + out.push_str(&rust_string_lit(id)); + out.push_str(", "); + out.push_str(&rust_string_lit(label)); + out.push_str(", "); + out.push_str(&rust_string_lit(shortcut)); + out.push_str(", "); + out.push_str(&rust_string_lit(params)); + out.push_str("),\n"); + } + out.push_str("];\n"); + out +} + +fn rust_string_lit(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c => out.push(c), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_literal_and_path_ids() { + let src = r#" +pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ + ( + crate::addons::arrange::ZOOM, + "Zoom to Selection", + "", + "{importer: \"plugin.\"}", + ), + ("app.language", "Interface Language", "Cmd+L", "{}"), +]; +"#; + let entries = parse_ui_commands(src).unwrap(); + assert_eq!(entries.len(), 2); + assert!(matches!(entries[0].id, IdExpr::Path(_))); + assert_eq!(entries[0].params, "{importer: \"plugin.\"}"); + assert_eq!(entries[1].id, IdExpr::Lit("app.language".into())); + } + + #[test] + fn escape_round_trip_no_extra_backslash() { + let s = "{importer: \"plugin.\"}"; + let lit = rust_string_lit(s); + assert_eq!(lit, r#""{importer: \"plugin.\"}""#); + assert!(!lit.contains(r#"\\\""#)); + } + + #[test] + fn find_const_in_source() { + let src = r#"pub const ZOOM: &str = "addon.arrange.zoomSelection";"#; + assert_eq!(find_str_const(src, "ZOOM").as_deref(), Some("addon.arrange.zoomSelection")); + } +} From bc6c20d0c9cb6d1cf127ff9dc77d9e4875a419c2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 08:00:42 -0800 Subject: [PATCH 25/49] JavaScript: a limit during an XML path assignment or list copy keeps the tree whole - A list copy holds each new copy as a root while the list is charged for it, so a collection at that point no longer frees the copy. Before, `x.setChildren( + )` near the heap limit could leave one child twice or end with an internal error. - An assignment to a path such as `x.a.b = v` or `x.a.b[0] = v` removes the elements it added for the path when a later step stops at a limit or throws. - Comparing XML values counts the attribute tables and the work stack against the heap limit. --- crates/js/src/xml.rs | 153 ++++++++++++++++++++++++++++--------- crates/js/src/xml_tests.rs | 56 ++++++++++++++ 2 files changed, 175 insertions(+), 34 deletions(-) diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index c84bd6a6db..9dc6d71f85 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -26,6 +26,11 @@ fn parser_units(s: &str) -> Vec { } pub(crate) const STAR: &[u16] = &[0x2A]; +/// The estimated bytes, for one attribute, of the copies of two attribute lists and the count of +/// names and values that comparing two XML values builds. +const ATTR_COUNT_BYTES: usize = 96; +/// The estimated bytes of one frame of the work stack that comparing two XML values uses. +const EQUALS_FRAME_BYTES: usize = 24; const XML_URI: &str = "http://www.w3.org/XML/1998/namespace"; const XMLNS_URI: &str = "http://www.w3.org/2000/xmlns/"; pub(crate) const RESERVED_NS_BINDING: &str = "a namespace declaration uses a reserved prefix or URI"; @@ -460,8 +465,10 @@ impl Interp { Ok(self.heap.alloc_object(Object::new(Class::XmlList(Box::new(data)), Some(proto)))) } + /// Adds `item` to the end of `list`. Both are held as roots across any collection the charge + /// starts. pub(crate) fn list_push(&mut self, list: ObjId, item: ObjId) -> JsResult<()> { - self.xml_charge(XML_REF_BYTES)?; + self.rooted(&[Value::Object(list), Value::Object(item)], |i| i.xml_charge(XML_REF_BYTES))?; self.xml_list_mut(list)?.items.push(item); Ok(()) } @@ -1297,8 +1304,34 @@ impl Interp { if n.attribute { self.put_attribute(x, n, &c) } else { self.put_element(x, n, &c) } } - /// [[ResolveValue]] (ECMA-357 section 9.2.1.10), walking the targets with a loop. - fn resolve_value(&mut self, x: ObjId) -> JsResult> { + /// [[ResolveValue]] (ECMA-357 section 9.2.1.10), walking the targets with a loop. Also returns + /// the outermost element it added, if any. An error takes the added elements back out. + fn resolve_value(&mut self, x: ObjId) -> JsResult)>> { + let mut created = None; + let r = self.resolve_value_adding(x, &mut created); + match (r, created) { + (Ok(Some(base)), created) => Ok(Some((base, created))), + (Ok(None), _) => Ok(None), + (Err(e), Some(c)) => { + self.undo_created(c)?; + Err(e) + } + (Err(e), None) => Err(e), + } + } + + /// Takes `created`, an element a path assignment added, back out of its parent. It takes no + /// fuel or heap. + fn undo_created(&mut self, created: ObjId) -> JsResult<()> { + if let Some(parent) = self.xml_node(created)?.parent { + self.xml_node_mut(parent)?.children.retain(|&c| c != created); + self.xml_node_mut(created)?.parent = None; + } + Ok(()) + } + + /// The steps of [`Self::resolve_value`], setting `created` to the first element they add. + fn resolve_value_adding(&mut self, x: ObjId, created: &mut Option) -> JsResult> { let mut chain = Vec::new(); let mut cur = x; let base = loop { @@ -1328,7 +1361,22 @@ impl Interp { { return Ok(None); } + let owner = match self.classify(&Value::Object(base)) { + Xv::Node(b) => Some(b), + Xv::List(b) => self.list_at(b, 0)?, + Xv::Other => None, + }; + let before = match owner { + Some(o) => self.children_len(o)?, + None => 0, + }; self.put_name(base, &n, &Value::str(""))?; + if created.is_none() + && let Some(o) = owner + && self.children_len(o)? > before + { + *created = self.xml_node(o)?.children.last().copied(); + } target = self.xml_select(base, &n)?; self.xml_keep(target); } @@ -1342,14 +1390,15 @@ impl Interp { Xv::Node(x) => self.node_put(x, n, v), Xv::List(l) => { if self.list_len(l)? == 0 { - let Some(r) = self.resolve_value(l)? else { return Ok(()) }; - let single = match self.classify(&Value::Object(r)) { - Xv::List(rl) if self.list_len(rl)? == 1 => self.list_at(rl, 0)?, - Xv::Node(rn) => Some(rn), - _ => None, - }; - let Some(single) = single else { return Ok(()) }; - self.list_push(l, single)?; + let Some((r, created)) = self.resolve_value(l)? else { return Ok(()) }; + let done = self.put_resolved(l, r, n, v); + if done.is_err() + && let Some(c) = created + { + self.undo_created(c)?; + self.xml_list_mut(l)?.items.clear(); + } + return done; } if self.list_len(l)? == 1 && let Some(item) = self.list_at(l, 0)? @@ -1362,17 +1411,41 @@ impl Interp { } } + /// Assigns `v` to the name `n` of `r`, the value [`Self::resolve_value`] gave for the empty + /// list `l`, when `r` is one node, and makes that node the item of `l`. + fn put_resolved(&mut self, l: ObjId, r: ObjId, n: &NameRef, v: &Value) -> JsResult<()> { + let single = match self.classify(&Value::Object(r)) { + Xv::List(rl) if self.list_len(rl)? == 1 => self.list_at(rl, 0)?, + Xv::Node(rn) => Some(rn), + _ => None, + }; + let Some(single) = single else { return Ok(()) }; + self.list_push(l, single)?; + self.node_put(single, n, v) + } + fn list_put_index(&mut self, x: ObjId, index: usize, v: &Value) -> JsResult<()> { let data = self.xml_list(x)?; let target = data.target; let target_name = data.target_name.clone(); - let r = match target { + let (r, created) = match target { Some(t) => match self.resolve_value(t)? { - Some(r) => Some(r), + Some((r, created)) => (Some(r), created), None => return Ok(()), }, - None => None, + None => (None, None), }; + let put = self.list_put_resolved(x, index, v, r, target_name.as_ref()); + if put.is_err() + && let Some(c) = created + { + self.undo_created(c)?; + } + put + } + + /// The steps of [`Self::list_put_index`] after the target `r` is resolved. + fn list_put_resolved(&mut self, x: ObjId, index: usize, v: &Value, r: Option, target_name: Option<&(XmlName, bool)>) -> JsResult<()> { if index >= self.list_len(x)? { let r = match r { Some(r) => match self.classify(&Value::Object(r)) { @@ -1391,7 +1464,7 @@ impl Interp { { return Ok(()); } - let (kind, name) = match &target_name { + let (kind, name) = match target_name { Some((name, true)) => { if let Some(r) = r { let n = NameRef { uri: name.uri.clone(), local: name.local.clone(), attribute: true }; @@ -1672,8 +1745,12 @@ impl Interp { if x.attrs.is_empty() { return Ok(true); } + let count = x.attrs.len(); + self.xml_take_fuel(count)?; + let mut held = HeldBytes::new(self); + held.add(self, ATTR_COUNT_BYTES.saturating_mul(count))?; + let (x, y) = (self.xml_node(a)?, self.xml_node(b)?); let (xa, ya) = (x.attrs.clone(), y.attrs.clone()); - self.xml_take_fuel(xa.len())?; let mut counts: HashMap<(Option, Key, Key), usize> = HashMap::new(); let attr_key = |i: &Self, id: ObjId| -> JsResult<(Option, Key, Key)> { let n = i.xml_node(id)?; @@ -1692,27 +1769,35 @@ impl Interp { Ok(true) } - /// [[Equals]] of two XML values (ECMA-357 section 9.1.1.9). + /// [[Equals]] of two XML values (ECMA-357 section 9.1.1.9). The bytes it holds count against + /// the heap limit, and `a` and `b` are held as roots. pub(crate) fn node_equals(&mut self, a: ObjId, b: ObjId) -> JsResult { - if !self.same_shallow(a, b)? { - return Ok(false); - } - let mut stack = vec![(a, b, 0usize)]; - while let Some(&(x, y, next)) = stack.last() { - self.use_fuel()?; - let (Some(cx), Some(cy)) = (self.child_at(x, next)?, self.child_at(y, next)?) else { - stack.pop(); - continue; - }; - if let Some(frame) = stack.last_mut() { - frame.2 += 1; - } - if !self.same_shallow(cx, cy)? { + self.rooted(&[Value::Object(a), Value::Object(b)], |i| { + if !i.same_shallow(a, b)? { return Ok(false); } - stack.push((cx, cy, 0)); - } - Ok(true) + let mut held = HeldBytes::new(i); + held.add(i, EQUALS_FRAME_BYTES)?; + let mut stack = vec![(a, b, 0usize)]; + while let Some(&(x, y, next)) = stack.last() { + i.use_fuel()?; + let (Some(cx), Some(cy)) = (i.child_at(x, next)?, i.child_at(y, next)?) else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.2 += 1; + } + if !i.same_shallow(cx, cy)? { + return Ok(false); + } + if stack.len() == stack.capacity() { + held.add(i, EQUALS_FRAME_BYTES.saturating_mul(stack.len()))?; + } + stack.push((cx, cy, 0)); + } + Ok(true) + }) } fn list_equals(&mut self, l: ObjId, v: &Value) -> JsResult { diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 40d66fd645..3d801e050a 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -962,6 +962,62 @@ fn a_limit_during_normalize_does_not_repeat_text() { assert!(fuel_stops(setup, "x.normalize()", check) > 0); } +/// Runs `action` as [`heap_stops`] does, and asserts that every run completes or stops at the heap +/// limit and that `check` is true after it. Returns how many runs stopped. +fn heap_stops_only(setup: &str, action: &str, check: &str) -> usize { + let mut r = realm(); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + let mut stops = 0; + for spare in 0..100_000i64 { + r.set_limits(Limits::default()); + r.run(setup).unwrap(); + let action = action.replace("SPARE", &format!("squeeze({})", spare - 512)); + let got = r.run(&action); + r.set_limits(Limits::default()); + assert_eq!(r.run(check), Ok(JsValue::Bool(true)), "{action}: {got:?}"); + match got { + Ok(_) => return stops, + Err(e) if e.kind == RunErrorKind::Limit(LimitKind::Heap) => stops += 1, + Err(e) => panic!("{action}: {e:?}"), + } + } + panic!("{action} never completed"); +} + +#[test] +fn collections_while_a_list_is_copied_keep_every_copy() { + let setup = "var x = ; var v = + ; var w = undefined;"; + let set = tree_is("x", "", ""); + for action in ["x.setChildren((SPARE, v))", "x.* = (SPARE, v)"] { + assert!(heap_stops_only(setup, action, &set) > 0, "{action}"); + } + let copied = "w === undefined || (w.length() === 2 && w[0].name() == 'c' && w[1].name() == 'd' && w[0] !== v[0])"; + assert!(heap_stops_only(setup, "w = (SPARE, v).copy()", copied) > 0); +} + +#[test] +fn a_limit_during_path_assignment_leaves_no_new_element() { + let setup = "var x = ; var v = ;"; + for (target, value, after) in [ + ("x.a.b.c", "v", ""), + ("x.a.b", "'text'", "text"), + ("x.a.@p", "'text'", ""), + ("x.a.b[0]", "'text'", "text"), + ] { + let check = tree_is("x", "", after); + assert!(fuel_stops(setup, &format!("{target} = {value}"), &check) > 0, "{target}"); + assert!(heap_stops_only(setup, &format!("{target} = (SPARE, {value})"), &check) > 0, "{target}"); + } +} + +#[test] +fn comparing_xml_values_is_charged_to_the_heap() { + let setup = "var x = t; var y = x.copy();"; + assert!(heap_stops_only(setup, "(SPARE, x == y)", "x == y && y.@q == '2'") > 0); +} + #[test] fn an_interrupt_stops_serialization() { let mut r = realm(); From 8eb3788eba31f02dac93d6cc2d0ed68f99888f1f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 08:07:27 -0800 Subject: [PATCH 26/49] Plug-ins: setting the plug-ins folder again after clearing it installs its plug-ins Clearing Preferences > Plug-ins & Scratch Disks > Additional Plug-ins Folder left the folder recorded as the one last loaded. Setting the same folder again then matched that record, and nothing in it was installed, so a plug-in removed in the meantime stayed removed. Clearing the preference now forgets the folder when it is the one last loaded. --- crates/engine/src/cmd/plugin.rs | 9 +++++++++ crates/engine/src/cmd/prefscmds.rs | 3 +++ crates/engine/src/tests/plugins.rs | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index d583f6fe34..a5b71d538b 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -846,6 +846,15 @@ pub(crate) fn sync_prefs(s: &Session) { *last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder))); } +/// The Additional Plug-ins Folder preference was cleared from `old`: when `old` is the folder last +/// loaded, it is forgotten, so setting the preference to it again loads it again. +pub(crate) fn forget_prefs_folder(old: &str) { + let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + if last.as_ref().is_some_and(|(f, _, _)| f == old.trim()) { + *last = None; + } +} + /// Turns Safe Mode on for `--safe-mode`, before the plug-in folder is attached: no plug-in loads or /// runs in this process from then on. pub fn start_in_safe_mode() { diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 783ce0e919..a92b4095a9 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -697,6 +697,9 @@ impl Session { let history_changed = p.history_states != self.prefs.history_states; let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color; let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder; + if p.plugins_folder.trim().is_empty() && !self.prefs.plugins_folder.trim().is_empty() { + super::plugin::forget_prefs_folder(&self.prefs.plugins_folder); + } let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions; let corner_radius_changed = p.corner_radius != self.prefs.corner_radius; let lock_guides_changed = p.lock_guides_by_default != self.prefs.lock_guides_by_default; diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 04e7b7784e..3c647deb01 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1315,6 +1315,25 @@ fn plugin_management_commands() { std::fs::remove_dir_all(&dir).ok(); } +/// Clearing the Additional Plug-ins Folder forgets the folder: setting the same folder again +/// installs what is in it, as setting any other folder does. +#[test] +fn plugins_folder_set_again_after_clearing_installs_its_plugins() { + let dir = crate::tests_links::Folder::new("plugin-folder-cleared"); + let id = "test.engine.foldercleared"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + std::fs::write(dir.0.join("cleared.wasm"), wat::parse_str(src).unwrap()).unwrap(); + let mut s = session(); + let folder = json!({"key": "pluginsFolder", "value": dir.0.display().to_string()}); + run(&mut s, "prefs.set", folder.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed from the folder"); + run(&mut s, "plugin.remove", json!({"id": id})); + run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": ""})); + run(&mut s, "prefs.set", folder); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again when the folder is set again"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// #2541: a plug-in installed in a session with a plug-in store is installed again in the next /// session that attaches the store, and Remove Plug-in takes it out of the store. #[test] From 9f80b4daae5c52b87b8d6c25a37f458e71c45887 Mon Sep 17 00:00:00 2001 From: jmverges Date: Wed, 11 Nov 2026 14:07:46 +0000 Subject: [PATCH 27/49] Type: aligned lines leave the tracking after their last character out (#6610) A centred or right-aligned line of tracked type was measured with the tracking after its last character, so it sat half that tracking (centred) or the whole tracking (right-aligned) left of where the page of users' .ai files draws it. That page aligns a line as wide as its characters without that tracking. Shaping now records the tracking each glyph's advance holds (SGlyph::tracking), and a line that isn't justified leaves its last character's out of the width it is aligned by. The last character is the last one the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves; spaces or a tab typed before a paragraph's end or a forced line break keep the letter's tracking, as the page does. Forced line breaks and wraps aren't in the files: they follow the rule so that turning a wrap into a paragraph or a forced line break keeps the line in place. Hyphenated lines, lines whose last character hangs, vertical and right-to-left lines and type on a path keep it. The tracking stays in the glyph's advance, so the caret, selections, line breaking and Fit Headline are unchanged. LineInfo::trailing gives the amount. Right-aligned area type sets the text after its last tab the same way. What aligns by line widths follows. Convert To Area Type makes its frame end where the lines are aligned, with room for the tracking, so tracked type converted either way stays in place. Affinity import lines up differently aligned paragraphs of artistic text by the aligned widths. SVG import moves a centred or right-aligned letter-spaced line back by its share of the last letter spacing, so it stays where a reader draws it and SVG export, which anchors lines at the end of their advances, reads back unchanged. Centred and right-aligned tracked type lays out differently than before in every document. Co-Authored-By: Claude Opus 5.5 --- .../src/cmd/addons/cleanup/text/tests.rs | 24 ++++ .../engine/src/cmd/fileio/affinity/layout.rs | 3 +- crates/engine/src/cmd/typemenu.rs | 13 +- crates/engine/src/tests/textedit.rs | 56 ++++++++ crates/engine/tests/it/affinity_features.rs | 6 +- crates/svg/src/import/text.rs | 50 +++++++ crates/svg/tests/it/import_text.rs | 32 +++++ crates/text/src/justify.rs | 1 + crates/text/src/layout.rs | 2 + crates/text/src/layout/flow.rs | 43 +++++- crates/text/src/layout/on_path.rs | 2 + crates/text/src/layout/wrapping_tests.rs | 1 + crates/text/src/lib.rs | 8 ++ crates/text/src/shape.rs | 16 ++- crates/text/src/tests.rs | 131 ++++++++++++++++++ crates/text/src/tests/bidi.rs | 1 + docs/file-format-parity.md | 3 +- docs/type-parity.md | 3 +- 18 files changed, 381 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index 27d0233bb6..1916d9dd20 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -125,6 +125,30 @@ fn break_area_type_into_lines_paragraphs_and_glyphs() { } } +/// Tracked area type broken into lines or paragraphs stays where it was, however it is aligned: +/// a wrap after a hyphen or at a space becomes a line break or a paragraph's end, and the line +/// leaves the tracking after its last character out of its width there as it did at the wrap. +/// (Words chosen without a kerning pair across the wrap, which the pieces don't keep.) +#[test] +fn break_tracked_area_type_keeps_its_aligned_lines_in_place() { + for justify in ["center", "right"] { + for text_of in ["Mode-Mode-Mode", "Wave Wave Wave"] { + for by in ["paragraphs", "lines"] { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 40, "text": text_of, "size": 20, "area": {"width": 90, "height": 200}})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": 200})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": justify})).unwrap(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)); + assert!(lay.lines.len() > 1, "{text_of}: it wraps"); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let pieces = ids(&s.execute("addon.cleanup.breakText", &json!({"by": by})).unwrap()); + assert_same_places(&was, &glyphs(&s, &pieces)); + } + } + } +} + #[test] fn break_text_refuses_what_it_cannot_break() { let mut s = session(); diff --git a/crates/engine/src/cmd/fileio/affinity/layout.rs b/crates/engine/src/cmd/fileio/affinity/layout.rs index 8b6d4c4a8f..262f688b13 100644 --- a/crates/engine/src/cmd/fileio/affinity/layout.rs +++ b/crates/engine/src/cmd/fileio/affinity/layout.rs @@ -157,7 +157,8 @@ pub(super) fn align_lines(obj: &mut TextObject) { return; } let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines; - let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); + // Each line as wide as VectorCraft aligns it: without the tracking after its last character. + let w = lines.iter().map(|l| l.x1 - l.trailing - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); if !(w > 0.0 && w < 1e6) { return; } diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 8d40002b3d..0ff46b1c8c 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -293,11 +293,20 @@ fn to_area(s: &mut Session, p: &Value) -> Result { continue; } let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + let mut b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + // Across, the frame spans the lines as they are aligned: from their starts to their ends + // without the tracking after their last characters (`LineInfo::trailing`), not to the + // ink, which tracking can pull past an advance. Area type aligns them alike. + let (x0, x1) = lay.lines.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, z), l| (a.min(l.x0), z.max(l.x1 - l.trailing))); + if x0.is_finite() && x1.is_finite() && x0 <= x1 { + (b.x0, b.x1) = (x0, x1); + } + // Line breaking counts that tracking: the slack holds the most of it. + let trailing = lay.lines.iter().map(|l| l.trailing).filter(|t| t.is_finite()).fold(0.0, f64::max); let size = t.first_style().size; // A little slack on the right so the text doesn't rewrap. let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0); - let slack = size * 0.5; + let slack = size * 0.5 + trailing; // Slack goes where the alignment leaves room, so the text doesn't move or rewrap. let (sl, sr) = match t.para_at(0).justify { Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index f49a88c80b..6aa42f7d9f 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2467,6 +2467,62 @@ fn convert_area_point_keeps_text_in_place_for_every_alignment() { } } +/// Each line's first glyph in the document, and the line count. +fn line_starts_doc(t: &TextObject) -> Vec { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + lay.lines.iter().map(|l| t.xf * lay.glyphs[l.glyph_start].origin).collect() +} + +/// Tracked type converted between point and area type stays where it is, however it is aligned, +/// as often as it goes round: an aligned line leaves the tracking after its last character out +/// of its width, and the frame Convert To Area Type makes ends where the lines are aligned, with +/// room for that tracking (line breaking counts it). Lines area type wraps at a space or after a +/// hyphen keep their place as the paragraphs Convert To Point Type makes of them (words without a +/// kerning pair across the wrap, which a new paragraph doesn't keep). +#[test] +fn converting_tracked_type_between_point_and_area_keeps_it_in_place() { + for j in ["left", "center", "right"] { + for tracking in [200.0, 1000.0, -100.0] { + let mut s = session(); + let id = + NodeId(s.execute("text.create", &json!({"x": 50, "y": 100, "text": "Wave\nWaves", "size": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": tracking})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert_eq!(before.len(), 2); + for round in 0..2 { + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let area = line_starts_doc(&obj(&s, id)); + assert_eq!(area.len(), 2, "{j} {tracking}: no rewrap"); + for (a, b) in area.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "{j} {tracking}, round {round}, area: {before:?} → {area:?}"); + } + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let point = line_starts_doc(&obj(&s, id)); + for (a, b) in point.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "{j} {tracking}, round {round}, point: {before:?} → {point:?}"); + } + } + } + // Area type wrapped at a space and after a hyphen, to point type. + for text in ["Wave Wave Wave", "Mode-Mode-Mode"] { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 50, "y": 100, "text": text, "size": 20, "area": {"width": 90, "height": 200}})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": 200})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert!(before.len() > 1, "{text}: it wraps"); + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let after = line_starts_doc(&obj(&s, id)); + assert_eq!(after.len(), before.len(), "{text}"); + for (a, b) in after.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "{j} {text}: {before:?} → {after:?}"); + } + } + } +} + #[test] fn convert_area_to_point_keeps_a_deeper_list_item_in_place() { let mut s = session(); diff --git a/crates/engine/tests/it/affinity_features.rs b/crates/engine/tests/it/affinity_features.rs index 511fbb25fe..6a0338ee17 100644 --- a/crates/engine/tests/it/affinity_features.rs +++ b/crates/engine/tests/it/affinity_features.rs @@ -568,12 +568,14 @@ fn artistic_text_aligns_its_lines_within_the_longest() { assert!(matches!(t.kind, TextKind::Point)); let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines; let (wide, mid) = (&lines[0], &lines[1]); - let w = wide.x1 - wide.x0; + // Each line as wide as it is aligned: without the tracking after its last character. + let width = |l: &vectorcraft_text::LineInfo| l.x1 - l.trailing - l.x0; + let w = width(wide); assert!(w > 0.0); // The block stays where the first paragraph's alignment puts it on the anchor. let start = [0.0, -0.5, -1.0][first as usize] * w; assert!((wide.x0 - start).abs() < 1e-6, "{first}/{second}: {} vs {start}", wide.x0); - let expected = wide.x0 + (w - (mid.x1 - mid.x0)) * at; + let expected = wide.x0 + (w - width(mid)) * at; assert!((mid.x0 - expected).abs() < 1e-6, "{first}/{second}: {} vs {expected}", mid.x0); // Each paragraph keeps its own alignment. use vectorcraft_doc::text::Justify; diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 904b76cd13..b11ce6af33 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -1060,6 +1060,7 @@ impl Ctx { } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); } + keep_reader_anchors(&mut obj); // The placeholder covers the text (laid out when a paint server needs its bounding box). let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default(); let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0)); @@ -1264,6 +1265,55 @@ fn lines( (Justify::Left, left, None) } +/// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor` +/// aligns a line's advance, which ends after the letter spacing of its last character (SVG +/// export anchors lines so); VectorCraft aligns a line without that tracking +/// ([`vectorcraft_text::LineInfo::trailing`]). Each such line moves back by its share of it: the +/// type moves when every line moves alike, otherwise each paragraph's right indent takes the +/// tracking (a line of imported point type is a paragraph). Browsers add that letter spacing +/// today; CSS WG issue 1518 and Firefox bugs 1907305 and 1913554 may drop it at a line's end. +fn keep_reader_anchors(obj: &mut TextObject) { + let share = |j: Justify| match j { + Justify::Center => 0.5, + Justify::Right => 1.0, + _ => 0.0, + }; + // Laid out only when a line may move: tracked type with a centred or right-aligned paragraph. + if !matches!(obj.kind, TextKind::Point) + || obj.vertical + || obj.runs.iter().all(|r| r.style.tracking == 0.0) + || obj.paragraph_styles().iter().all(|p| share(p.justify) == 0.0) + { + return; + } + let plain = obj.plain_text(); + // Each line's paragraph, its tracking left out and how far that moves it. + let lines: Vec<(usize, f64, f64)> = measure(obj) + .lines + .iter() + .map(|l| { + let para = plain.get(..l.start).map_or(0, |before| before.matches('\n').count()); + let trailing = if l.trailing.is_finite() { l.trailing } else { 0.0 }; + (para, trailing, share(obj.para_at(para).justify) * trailing) + }) + .collect(); + let Some(&(_, _, first)) = lines.first() else { return }; + if first == 0.0 && lines.iter().all(|l| l.2 == 0.0) { + return; + } + if lines.iter().all(|l| (l.2 - first).abs() < 1e-9) { + obj.xf *= Affine::translate((-first, 0.0)); + return; + } + let mut styles = obj.paragraph_styles(); + for &(para, trailing, moves) in &lines { + if let Some(style) = styles.get_mut(para).filter(|_| moves != 0.0) { + style.right_indent += trailing; + } + } + obj.set_paragraph_styles(styles); +} + /// A left-aligned point text of `runs` at the origin (for measuring). fn with_runs(runs: Vec) -> TextObject { let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index a30bc193b0..35289a0c1d 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -245,6 +245,38 @@ fn per_character_positions_with_middle_anchor() { assert!(close(mid(0), 50.0, 0.05) && close(mid(1), 100.0, 0.05), "{} {}", mid(0), mid(1)); } +/// `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, +/// and VectorCraft aligns a line without that tracking: the imported lines move back by their +/// share of it and stay where a reader draws them, and a centred or right-aligned tracked line +/// exported and read back stays where it was (export anchors lines the same way). +#[test] +fn anchored_letter_spaced_lines_stay_where_a_reader_draws_them() { + let starts = |t: &TextObject| { + let lay = layout(FontDb::global(), t); + lay.lines.iter().map(|l| (t.xf * lay.glyphs[l.glyph_start].origin).x).collect::>() + }; + let advances = |t: &TextObject| { + let lay = layout(FontDb::global(), t); + lay.lines.iter().map(|l| lay.glyphs[l.glyph_start..l.glyph_end].iter().map(|g| g.advance).sum::()).collect::>() + }; + for (anchor, share) in [("middle", 0.5), ("end", 1.0)] { + let d = open(&format!(r#"WAVE"#)); + let t = only_text(&d); + let (start, advance) = (starts(t)[0], advances(t)[0]); + assert!(close(start, 200.0 - share * advance, 1e-6), "{anchor}: the line starts at {start}, not {}", 200.0 - share * advance); + let back = import(&vectorcraft_svg::export(&d, &vectorcraft_svg::ExportOptions::default())).unwrap(); + let again = starts(only_text(&back))[0]; + assert!(close(again, start, 1e-6), "{anchor}: read back, the line starts at {again}, not {start}"); + } + // Lines anchored differently: each moves by its own share. + let d = open( + r#"WaveLine"#, + ); + let t = only_text(&d); + let (s, a) = (starts(t), advances(t)); + assert!(close(s[0], 200.0 - a[0] / 2.0, 1e-6) && close(s[1], 200.0 - a[1], 1e-6), "{s:?} {a:?}"); +} + #[test] fn tspan_lines_dx_and_baseline_shift() { let d = open( diff --git a/crates/text/src/justify.rs b/crates/text/src/justify.rs index a3a1419402..53de928be2 100644 --- a/crates/text/src/justify.rs +++ b/crates/text/src/justify.rs @@ -89,6 +89,7 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g let base = g.adv; if g_des != 1.0 { g.adv *= g_des; + g.tracking *= g_des; g.sx *= g_des; g.dx *= g_des; g.kern_shift *= g_des; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 15b71ae233..11dcd1eeb4 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -659,6 +659,7 @@ mod emit_kashida_mark_last { len: 2, run: 0, adv: 10.0, + tracking: 0.0, dx: 0.0, dy: 0.0, kern_shift: 0.0, @@ -719,6 +720,7 @@ mod emit_kashida_mark_last { len: 4, run: 1, adv: 0.0, + tracking: 0.0, dx: 0.0, dy: 0.0, kern_shift: 0.0, diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index e1d55eece6..56c71c2f4b 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -389,6 +389,9 @@ struct LineGeometry { ax1: f64, width: f64, render_width: f64, + /// The tracking after the line's last character, when that character ends the line (see + /// [`prepare_line_geometry`]); 0 otherwise. + trailing: f64, order: Vec, drawn: Vec, opt_l: f64, @@ -413,6 +416,9 @@ struct LineJustification { /// Right-aligned area type: the line's last tab, which takes the text after it to the right /// edge (the text before it sits at its stops, from the left edge). last_tab: Option, + /// The tracking after the line's last character that its alignment leaves out of its width: + /// [`LineGeometry::trailing`] for a line that isn't justified, 0 for one that is. + trailing: f64, } fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { @@ -582,6 +588,31 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData // ends with the empty line after it (`run_paragraph_lines`). let soft_return = position.end > index && sg.get(position.end - 1).is_some_and(|g| g.ch == '\u{2028}'); let last_of_para = position.end >= n && !soft_return; + // The tracking after the line's last character: an aligned line is measured without it. The + // page of users' `.ai` files sets a centred line of tracked type half that tracking right of + // where a line measured with it goes, and a right-aligned one the whole tracking right (in + // lines that end a paragraph). The last character is the last one the line draws before a + // paragraph's end, a forced line break, or the spaces the layout wraps the line at (they hang + // past it). Spaces or a tab typed before a paragraph's end or a forced line break are the + // line's last characters, and the letter before them keeps its tracking: the page counts it in + // the 2 lines of the files that end so. Forced line breaks and wraps aren't in the files; they + // take the rule so that turning a wrap into a forced line break or a new paragraph (Break + // Text, Convert To Point Type) keeps the line where it was. A line a hyphen ends, one whose + // last character hangs, and vertical and right-to-left lines keep the tracking. + let tail = sg.get(trimmed..position.end).unwrap_or_default(); + let ends_drawn = match tail.split_last() { + None => true, + Some((last, before)) if last.ch == '\u{2028}' => before.is_empty(), + // Spaces at a wrap: the paragraph goes on. + Some(_) => position.end < n, + }; + let trailing = trimmed + .checked_sub(1) + .and_then(|k| sg.get(k)) + .filter(|g| { + ends_drawn && trimmed > index && !g.level.is_rtl() && !cx.vertical && !paragraph.rtl && hyphen.is_none() && hang.is_none() && opt_r == 0.0 + }) + .map_or(0.0, |g| if g.tracking.is_finite() { g.tracking } else { 0.0 }); LineGeometry { position, index, @@ -593,6 +624,7 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData ax1, width, render_width, + trailing, order, drawn, opt_l, @@ -738,6 +770,9 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin // A tab ending the line counts: its text sits at the left edge, the tab up to the right one. .then(|| (line.index..line.position.end).rev().find(|&j| sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0))) .flatten(); + // An aligned line is as wide as its characters without the tracking after the last one. + let trailing = if justify || pushed_in { 0.0 } else { line.trailing }; + let width = width - trailing; let start_x = if justify || pushed_in || last_tab.is_some() { line.ax0 - line.opt_l } else if paragraph.regions.is_none() { @@ -753,7 +788,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab } + LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing } } /// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop @@ -780,10 +815,11 @@ fn tab_glyph<'p>( return (advance, leader); } // The width of what follows up to the line's end (nothing, when the tab ends the line), with - // the hyphen a hyphenated line ends with. + // the hyphen a hyphenated line ends with, and without the tracking after its last character, + // as a right-aligned line is measured. let after = sg.get(glyph_index + 1..line.trimmed).unwrap_or_default(); let hyphen = line.hyphen.as_ref().map_or(0.0, |g| g.adv); - let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim + hyphen }; + let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim + hyphen - justification.trailing }; ((line.ax1 + line.opt_r - rest - x).max(0.0), leader) } @@ -905,6 +941,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData baseline: position.baseline, x0: start_x, x1: x_end, + trailing: justification.trailing, ascent: position.m.asc, descent: position.m.desc, first_baseline: if pen.model == LeadingModel::EmBoxTop { diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index 398928ee98..ac5a53d474 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -190,6 +190,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, baseline: 0.0, x0: 0.0, x1: 0.0, + trailing: 0.0, ascent: m.0, descent: m.1, first_baseline: 0.0, @@ -417,6 +418,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, baseline: ps.y, x0: ps.x, x1: pe.x, + trailing: 0.0, ascent: m.0, descent: m.1, first_baseline: 0.0, diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index c94d25f897..920e802b7a 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -16,6 +16,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { len, run: 0, adv, + tracking: 0.0, dx: 0.0, dy: 0.0, kern_shift: 0.0, diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 011bb6eb20..04b971dfb3 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -285,6 +285,14 @@ pub struct LineInfo { pub x0: f64, /// Right edge of the line content, trailing spaces excluded. pub x1: f64, + /// The tracking after the line's last character (in `x1` and in its glyph's advance) that the + /// line's alignment leaves out: the line is aligned as if it ended this much before `x1` (a + /// centred line is centred and a right-aligned one, or the text after the last tab of + /// right-aligned area type, ends there; a left-aligned line starts where it would anyway). 0 + /// when spaces or a tab typed before a paragraph's end or a forced line break end the line, + /// when a hyphen ends it or its last character hangs, in justified lines and lines that push + /// characters in, in vertical and right-to-left lines, and in type on a path. + pub trailing: f64, pub ascent: f64, pub descent: f64, /// Area type: how far the line's baseline sits below the top of its frame cell (inside the diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index a58e8ef57e..3c56b2c914 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -30,6 +30,10 @@ pub(crate) struct SGlyph { pub run: usize, /// Advance in points (tracking, manual kerning and horizontal scale applied). pub adv: f64, + /// The tracking `adv` holds after the glyph's character, in points: on the last glyph of its + /// cluster, 0 on the others. An aligned line leaves its last character's out of its width + /// ([`crate::layout`]). + pub tracking: f64, /// Offset from the pen position, y down. pub dx: f64, pub dy: f64, @@ -177,6 +181,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph { let mut h = g.clone(); h.gid = gid; h.adv = g.face.advance(gid) * g.sx; + h.tracking = 0.0; h.dx = 0.0; h.dy = 0.0; h.kern_shift = 0.0; @@ -976,9 +981,9 @@ fn inline_glyph( _ => (kurbo::Affine::IDENTITY, h, None), }; let spacing_em = size * spacing_scale(st, vertical); - let tracking = st.tracking / 1000.0 * spacing_em; + let tracking = Some(st.tracking / 1000.0 * spacing_em).filter(|t| t.is_finite()).unwrap_or(0.0); let manual_kern = st.kerning.map(|v| v / 1000.0 * spacing_em).filter(|k| k.is_finite()).unwrap_or(0.0); - let adv = width + if tracking.is_finite() { tracking } else { 0.0 } + manual_kern; + let adv = width + tracking + manual_kern; SGlyph { face: face.clone(), gid: 0, @@ -986,6 +991,7 @@ fn inline_glyph( len: range.len().max(1), run, adv: if adv.is_finite() { adv } else { 0.0 }, + tracking: if adv.is_finite() { tracking } else { 0.0 }, dx: 0.0, dy: 0.0, kern_shift: if level.is_rtl() && !vertical { manual_kern } else { 0.0 }, @@ -1396,6 +1402,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe None => gid, }; let mut kern_shift = 0.0; + let mut tracked = 0.0; if ch == SOFT_HYPHEN || ch == '\u{2028}' { adv = 0.0; } else { @@ -1407,8 +1414,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe kern_shift = manual_kern; } if last_in_cluster { - let tracking = gst.tracking / 1000.0 * spacing_em; - adv += tracking + manual_kern; + tracked = gst.tracking / 1000.0 * spacing_em; + adv += tracked + manual_kern; } } out.push(SGlyph { @@ -1418,6 +1425,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe len: end.saturating_sub(cl).max(1), run, adv, + tracking: tracked, dx: xo as f64 * k * hs, dy: -(yo as f64) * k * vs, kern_shift, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a7a0724ee1..8934090d5a 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -252,6 +252,137 @@ fn tracking_and_manual_kerning_take_the_horizontal_scale() { } } +/// A centred or right-aligned line is as wide as its characters without the tracking after the +/// last one, as the page of users' `.ai` files aligns tracked lines: centred half that tracking +/// right of a line measured with it, right-aligned the whole tracking right, so the last glyph's +/// own advance ends on the anchor or the frame's edge. The tracking is still in the last glyph's +/// advance (the caret after it, and a selection of it, keep it). +#[test] +fn an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width() { + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + // 200/1000 of a 20 pt em: 4 pt after each character, also at 50% Horizontal Scale with 400. + for (tracking, h_scale, after) in [(200.0, 100.0, 4.0), (-100.0, 100.0, -2.0), (400.0, 50.0, 4.0)] { + let st = CharStyle { tracking, h_scale, ..style(20.0) }; + let mut t = point("Wave", st.clone()); + let w = width(&t); + let start = |t: &TextObject| layout(db(), t).glyphs[0].origin.x; + let end = |t: &TextObject| { + let l = layout(db(), t); + let last = l.glyphs.last().unwrap(); + last.origin.x + last.advance + }; + assert!(near(start(&t), 0.0), "left aligned point type starts at its anchor"); + t.para.justify = Justify::Center; + assert!(near(start(&t), -(w - after) / 2.0), "{tracking} at {h_scale}%: centred from {}, not {}", -(w - after) / 2.0, start(&t)); + t.para.justify = Justify::Right; + assert!(near(end(&t) - after, 0.0), "{tracking} at {h_scale}%: right aligned, the last glyph ends {} past the anchor", end(&t) - after); + let plain = layout(db(), &TextObject { runs: vec![TextRun::new("Wave", CharStyle { tracking: 0.0, ..st.clone() })], ..t.clone() }); + let tracked = layout(db(), &t); + let last_advance = |l: &TextLayout| l.glyphs.last().unwrap().advance; + assert!(near(last_advance(&tracked) - last_advance(&plain), after), "{tracking}: the last glyph keeps its tracking"); + // Area type: the same in its frame. + let frame = Rect::new(0.0, 0.0, 300.0, 100.0); + let centred = area("Wave", st.clone(), frame, Justify::Center); + assert!(near(start(¢red), (300.0 - (w - after)) / 2.0), "{tracking}: centred area type starts at {}", start(¢red)); + let right = area("Wave", st.clone(), frame, Justify::Right); + assert!(near(end(&right) - after, 300.0), "{tracking}: right aligned area type ends {} past its edge", end(&right) - after - 300.0); + // A line that ends in a space keeps its last letter's tracking: the space comes after it. + let spaced = area("Wave ", st, frame, Justify::Right); + let l = layout(db(), &spaced); + let e = l.glyphs.iter().find(|g| g.byte == 3).unwrap(); + assert!(near(e.origin.x + e.advance, 300.0), "{tracking}: before a space the line ends at {}", e.origin.x + e.advance); + } +} + +/// Right-aligned area type with a tab sets the text after its last tab as it sets a right-aligned +/// line: the last glyph's advance, less the tracking after it, ends on the frame's edge. +#[test] +fn the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out() { + let frame = Rect::new(0.0, 0.0, 336.0, 100.0); + let lay = layout(db(), &area("Item\t24.00", CharStyle { tracking: 200.0, ..style(13.0) }, frame, Justify::Right)); + assert!(lay.glyphs[0].origin.x.abs() < 1e-6, "the item starts at the left edge: {}", lay.glyphs[0].origin.x); + let last = lay.glyphs.last().unwrap(); + let after = 200.0 / 1000.0 * 13.0; + let end = last.origin.x + last.advance - after; + assert!((end - 336.0).abs() < 1e-6, "the price ends at {end}, not on the edge"); +} + +/// Which lines leave the tracking after their last character out of their width +/// (`LineInfo::trailing`): those a paragraph's end, a forced line break or a wrap ends, also a +/// wrap at a space (the space hangs past the line). Spaces typed before a paragraph's end or a +/// forced line break end the line themselves, and the letter before them keeps its tracking. So +/// do a line a hyphen ends, one whose last character hangs (burasagari, Optical Margin +/// Alignment), a justified line, right-to-left lines and lines ending in a right-to-left run, +/// vertical type and type on a path. +#[test] +fn which_lines_leave_the_tracking_after_their_last_character_out() { + let st = CharStyle { tracking: 200.0, ..style(20.0) }; + // 200/1000 of a 20 pt em. + let t = 4.0; + let trailing = |o: &TextObject| layout(db(), o).lines.iter().map(|l| l.trailing).collect::>(); + let centred = |text: &str| { + let mut o = point(text, st.clone()); + o.para.justify = Justify::Center; + o + }; + let narrow = Rect::new(0.0, 0.0, 90.0, 300.0); + assert_eq!(trailing(&area("Wave Wave", st.clone(), narrow, Justify::Center)), [t, t], "a wrap at a space"); + assert_eq!(trailing(&area("Wave-Wave", st.clone(), narrow, Justify::Center)), [t, t], "a wrap after a hyphen"); + assert_eq!(trailing(¢red("Wave\u{2028}Wave")), [t, t], "a forced line break"); + assert_eq!(trailing(¢red("Wave \u{2028}Wave")), [0.0, t], "a space typed before a forced line break"); + assert_eq!(trailing(¢red("Wave \nWave")), [0.0, t], "a space typed before a paragraph's end"); + // Hyphenated: the hyphen ends the line, and has no tracking. + let mut hyphenated = area("internationalization", st.clone(), Rect::new(0.0, 0.0, 120.0, 300.0), Justify::Right); + hyphenated.para.hyphenate = true; + hyphenated.para.composer = vectorcraft_doc::Composer::SingleLine; + let lay = layout(db(), &hyphenated); + assert!(lay.lines.len() > 1 && lay.lines[0].end < "internationalization".len(), "the word is hyphenated: {:?}", lay.lines); + assert_eq!(lay.lines[0].trailing, 0.0, "a hyphenated line"); + // Justified: justification counts it (only the paragraph's last line is aligned). + assert_eq!(trailing(&area("Wave Wave", st.clone(), narrow, Justify::JustifyLeft)), [0.0, t], "a justified line"); + // A last character that hangs. + let mut hanging = area("あいう。", st.clone(), Rect::new(0.0, 0.0, 300.0, 100.0), Justify::Center); + hanging.para.burasagari = vectorcraft_doc::Burasagari::Forced; + assert_eq!(trailing(&hanging), [0.0], "a full stop burasagari hangs"); + let mut optical = area("Wave.", st.clone(), Rect::new(0.0, 0.0, 300.0, 100.0), Justify::Right); + optical.optical_margin = true; + assert_eq!(trailing(&optical), [0.0], "a full stop Optical Margin Alignment hangs"); + optical.runs = vec![TextRun::new("Wave", st.clone())]; + assert_eq!(trailing(&optical), [t], "a letter that doesn't hang"); + // Right to left: a right-to-left paragraph ending in Latin, a Latin one ending in Hebrew. + assert_eq!(trailing(¢red("שלום abc")), [0.0], "a right-to-left paragraph"); + assert_eq!(trailing(¢red("abc שלום")), [0.0], "a right-to-left run ending the line"); + // Vertical type. + let mut vertical = centred("Wave"); + vertical.vertical = true; + assert_eq!(trailing(&vertical), [0.0], "vertical type"); + // Type on a path, centred: as wide as all its advances. + let mut on_path = centred("Wave"); + let w = width(&on_path); + let mut line = kurbo::BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let lay = layout(db(), &on_path); + assert_eq!(lay.lines[0].trailing, 0.0, "type on a path"); + assert!((lay.glyphs[0].origin.x - (500.0 - w) / 2.0).abs() < 1e-6, "{}", lay.glyphs[0].origin.x); +} + +/// Justification… Desired Glyph Scaling scales each glyph's advance with the tracking in it: an +/// aligned line leaves out its last character's tracking as scaled. +#[test] +fn an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it() { + let mut t = point("Wave", CharStyle { tracking: 400.0, ..style(20.0) }); + t.para.justify = Justify::Center; + t.para.justification.glyph_scaling = [80.0, 90.0, 110.0]; + let lay = layout(db(), &t); + let w: f64 = lay.glyphs.iter().map(|g| g.advance).sum(); + // 400/1000 of a 20 pt em, at 90%. + let left_out = 8.0 * 0.9; + assert!((lay.lines[0].trailing - left_out).abs() < 1e-9, "{}", lay.lines[0].trailing); + assert!((lay.glyphs[0].origin.x + (w - left_out) / 2.0).abs() < 1e-6, "centred from {}", lay.glyphs[0].origin.x); +} + /// The factor tracking and manual kerning take from the Horizontal Scale: the scale in horizontal /// type, 1 in vertical type and for a scale that isn't a positive number. #[test] diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index d7cd584013..69f9bdb668 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -176,6 +176,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout baseline, x0: 0.0, x1: x, + trailing: 0.0, ascent: 10.0, descent: 3.0, first_baseline: 0.0, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5cad08fbf7..f95e205de2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` save (PDF-compatible editing copy and legacy) and `.ai`/EPS open: a layer's `Lb` colour index follows the file's numbering, where Magenta, Cyan and Yellow are 4, 5 and 6 and `LAYER_COLORS` has Yellow, Magenta and Cyan; Yellow, Magenta and Cyan layers no longer come out as Magenta, Cyan and Yellow, and the other 24 presets are unchanged (#6502, from the index and RGB pairs in users' files). `LAYER_COLORS` keeps its order, so `.vectorcraft` files and the Layers panel don't change. Scores and estimates unchanged. | | 2026-11-11 | minor | `.ai` save: stroked type writes its stroke's cap, join and miter limit into the text document (keys 61, 62 and 64, as `.ai` import reads them since #6500), so they come back when the file opens again and no longer count among the attributes of type left out; a run without a stroke doesn't count them either, as they draw nothing. Tests `ai_editing::tests::type_comes_back_as_type`, `an_unstroked_run_with_a_stroke_style_loses_nothing` (follow-up to #6472) | | 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 4013f8be19..033979a246 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | Tracking and manual kerning in horizontally scaled type: they are thousandths of the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), so at 50% a tracking of 100 adds 0.05 em after each character, half as much as at 100%. They were added at the full em whatever the scale, so narrow tracked type drifted further right with each letter. Illustrator's own layout in users' `.ai` files (used locally, not committed) spaces such type so, and PDF scales its character spacing by its horizontal scaling alike (ISO 32000-1 §9.4.4). Inline graphics take it after them too; vertical type keeps the full em, its advances down the column not all taking the Horizontal Scale. The automatic kerning the Character panel reads, Touch Type's limits and Artwork Cleanup's Combine Point Text measure in the same em, and Scale Text Attributes now keeps tracked type where a horizontal stretch of its transform drew it. Type that has both a Horizontal Scale other than 100% and tracking or manual kerning lays out differently than before in every document. Tests `tests::tracking_and_manual_kerning_take_the_horizontal_scale`, `tests::typo::optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking`, `tests::inline::shift_tracking_and_missing_symbols`, `tests::vertical::vertical_tracking_keeps_the_em_at_any_horizontal_scale` and the engine's `horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it`, `scale_text_attributes_keeps_tracked_type_where_it_was` and `combine_puts_horizontally_scaled_pieces_back_where_they_were`. Scores and estimates unchanged. | | 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. Scores and estimates unchanged. | | 2026-11-10 | minor | Type on a path (#3565): Direct Selection shows the direction handles of selected type on a path's path, as of a path selected whole, and drags them (`text.setPathHandle`; Alt: one side, Shift: 45° steps); the text reflows along the new curve. Scores and estimates unchanged. | From 6e540361af45a633b4a49cb78d277f10e5148cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Wed, 11 Nov 2026 16:08:34 +0000 Subject: [PATCH 28/49] xtask: collapse if in ui-commands reexport parse --- xtask/src/ui_commands.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xtask/src/ui_commands.rs b/xtask/src/ui_commands.rs index bb8d8dfdd7..4bf12a2723 100644 --- a/xtask/src/ui_commands.rs +++ b/xtask/src/ui_commands.rs @@ -377,10 +377,10 @@ fn reexport_targets<'a>(src: &'a str, const_name: &str) -> Vec> { if hit { out.push(path.split("::").collect()); } - } else if let Some((path, name)) = rest.rsplit_once("::") { - if name.trim() == const_name { - out.push(path.split("::").collect()); - } + } else if let Some((path, name)) = rest.rsplit_once("::") + && name.trim() == const_name + { + out.push(path.split("::").collect()); } } out From de95640d93017d924e94cbe6d5841b276ae94d4a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 08:09:22 -0800 Subject: [PATCH 29/49] JavaScript: XML namespace declarations and QName strings count against the heap limit addNamespace and setNamespace now add a declaration's bytes to the heap total when the element gains a new prefix, and QName.prototype.toString adds the bytes of the string it returns. Before, both only checked for room, so a loop of distinct prefixes could grow an element past the heap limit until a collection recounted it. --- crates/js/src/xml_builtins.rs | 10 ++++++++-- crates/js/src/xml_tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index 1662dc5dbd..35c5788304 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -299,7 +299,9 @@ fn qname_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> Js _ => {} } units.extend_from_slice(&name.local); - interp.reserve_heap(string_bytes(units.len()))?; + let bytes = string_bytes(units.len()); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); Ok(Value::String(Str::from_units(units))) } @@ -816,7 +818,11 @@ fn add_in_scope(interp: &mut Interp, x: ObjId, ns: XmlNs) -> JsResult<()> { if prefix.is_empty() && node.name.as_ref().and_then(|n| n.uri.as_ref()).is_some_and(|u| u.is_empty()) { return Ok(()); } - interp.reserve_heap(crate::heap::XML_NS_BYTES)?; + if !node.ns.iter().any(|m| m.prefix.as_ref() == Some(&prefix)) { + let bytes = crate::heap::XML_NS_BYTES; + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + } let attrs = interp.xml_node(x)?.attrs.clone(); let node = interp.xml_node_mut(x)?; node.ns.retain(|m| m.prefix.as_ref() != Some(&prefix)); diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 3d801e050a..051c676a9d 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -1018,6 +1018,32 @@ fn comparing_xml_values_is_charged_to_the_heap() { assert!(heap_stops_only(setup, "(SPARE, x == y)", "x == y && y.@q == '2'") > 0); } +/// Runs `setup` with no limit, then `action` in the same realm, and returns how `action` ended. +fn after_setup(setup: &str, action: &str) -> Result { + let mut r = realm(); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + r.run(setup).unwrap(); + r.run(action).map_err(|e| e.kind) +} + +#[test] +fn namespace_declarations_and_qname_strings_are_charged_to_the_heap() { + let heap = Err(RunErrorKind::Limit(LimitKind::Heap)); + let distinct = "var x = ; var a = []; for (var i = 0; i < 400; i++) a.push(new Namespace('p' + i, 'u' + i));"; + for method in ["addNamespace", "setNamespace"] { + let action = format!("squeeze(4096); for (var i = 0; i < 400; i++) x.{method}(a[i]); 1"); + assert_eq!(after_setup(distinct, &action), heap, "{method}"); + } + let same = "var x = ; var n = new Namespace('p', 'u');"; + let action = "squeeze(4096); for (var i = 0; i < 400; i++) x.addNamespace(n); 1"; + assert_eq!(after_setup(same, action), Ok(JsValue::Number(1.0))); + let long = "var s = 'x'; for (var i = 0; i < 12; i++) s += s; var q = new QName('u', s);"; + let action = "squeeze(12000); var t1 = q.toString(); var t2 = q.toString(); 1"; + assert_eq!(after_setup(long, action), heap); +} + #[test] fn an_interrupt_stops_serialization() { let mut r = realm(); From 9ce1558c5e415964eaaaacb7fa884b7f11a4efcc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 08:21:15 -0800 Subject: [PATCH 30/49] Simplify: a closed subpath whose refit is one curve stays in the path A closed subpath that refits to a single curve has one anchor, which is not a subpath, so Simplify dropped it: a small circle inside a compound path was lost and the result ended hundreds of points from the original, and a small closed path on its own came out empty. Such a subpath is now refitted in two parts, cut at the point farthest from its start, or in three when two parts would still enclose no area (two lines that retrace each other with Convert to Straight Lines). A closed subpath of a single segment stays as it is. --- crates/pathops/src/edit.rs | 36 +++++++++++++++++++++++++++++++--- crates/pathops/tests/it/ops.rs | 31 ++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 80f0deffda..ced1db16e2 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -84,11 +84,14 @@ pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData { PathData::new(out) } -/// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it. +/// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it. A closed subpath +/// whose refit is one segment, or two lines that enclose no area, is refitted in two parts, cut at +/// the point farthest from its start, or in three when two still give that; one of a single segment +/// stays as it is. fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { let mut segs = subpath_segs(sp); let n = segs.len(); - if n == 0 { + if n == 0 || sp.closed && n < 2 { return Some(sp.clone()); } if sp.closed { @@ -101,7 +104,34 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { // along its chord, and `no_worse` can return the original subpath, so the start can keep a kink. let across = if sp.closed && opts.smooth > 0.0 { unit(end_tangent(&segs[n - 1].c) + start_tangent(&segs[0].c)) } else { None }; let across = across.filter(|_| (0..n).all(|i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) <= opts.corner_angle_deg)); - let result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across); + let mut result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across); + let flat = |r: &[Seg]| r.len() < 2 || r.len() == 2 && r.iter().all(|s| s.line); + if sp.closed && flat(&result) { + // Each segment in two, and the loop cut where they meet: first at the point farthest from its + // start, then also at the point farthest from the line between those two. + let halves: Vec = segs + .iter() + .flat_map(|s| { + let (a, b) = s.c.subdivide(); + [Seg { c: a, line: s.line }, Seg { c: b, line: s.line }] + }) + .collect(); + let m = halves.len(); + let at = |i: usize| halves[i].c.p0; + let farthest = |from: &dyn Fn(Point) -> f64, skip: usize| (1..m).filter(|&i| i != skip).max_by(|&a, &b| from(at(a)).total_cmp(&from(at(b)))); + let p0 = at(0); + let j = farthest(&|p| p.distance(p0), 0).unwrap_or(m / 2); + let chord = at(j) - p0; + let off_chord = |p: Point| if chord.hypot2() > 0.0 { chord.cross(p - p0).abs() } else { p.distance(p0) }; + let k = farthest(&off_chord, j).unwrap_or(m / 2); + let refit = |cuts: &[usize]| -> Vec { + cuts.windows(2).flat_map(|w| simplify_run_within(&halves[w[0]..w[1]], opts, MAX_RUN_SAMPLES / 3, None)).collect() + }; + result = refit(&[0, j, m]); + if flat(&result) && k != j { + result = refit(&[0, j.min(k), j.max(k), m]); + } + } let s = segs_to_subpath(&result, sp.closed)?; Some(if opts.straight_lines { s } else { no_worse(sp, s) }) } diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 587da33448..01e89efe08 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -1,7 +1,7 @@ use std::f64::consts::PI; use kurbo::{ParamCurve, Point, Rect, Shape as _}; -use vectorcraft_geom::{AnchorKind, FillRule, PathData, SubPath}; +use vectorcraft_geom::{Anchor, AnchorKind, FillRule, PathData, SubPath}; use vectorcraft_pathops::*; const NZ: FillRule = FillRule::NonZero; @@ -408,6 +408,35 @@ fn simplify_straight_lines_option() { assert!(max_deviation(&c, &s) <= 1.0 + 1e-6); } +/// A closed subpath whose refit is one curve stays a closed subpath that encloses an area, in a +/// compound path and on its own, with curves and with Convert to Straight Lines. +#[test] +fn simplify_keeps_a_closed_subpath_that_refits_to_one_curve() { + let small = |cx: f64, cy: f64| circle(cx, cy, 2.0).subpaths.remove(0); + let square = rect(0.0, 0.0, 1000.0, 1000.0).subpaths.remove(0); + let compound = PathData::new(vec![square, small(500.0, 500.0)]); + let lone = PathData::single(small(0.0, 0.0)); + // A straight edge and a curve that bows 2.25 pt from it. + let bow = PathData::single(SubPath::new( + vec![ + Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 3.0), Point::new(0.0, 0.0)), + Anchor::with_handles(Point::new(100.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 3.0)), + ], + true, + )); + for straight_lines in [false, true] { + for tolerance in [4.0, 10.0, 32.0] { + let opts = SimplifyOptions { tolerance, straight_lines, ..Default::default() }; + for path in [&compound, &lone, &bow] { + let s = simplify_with(path, &opts); + assert_eq!(s.subpaths.len(), path.subpaths.len(), "{tolerance} pt, straight lines {straight_lines}: {s:?}"); + assert!(s.subpaths.iter().all(|sp| sp.closed && sp.anchors.len() >= 2 && sp.area().abs() > 1.0), "{s:?}"); + assert!(max_deviation(path, &s) <= tolerance + 1e-6, "{tolerance} pt: {}", max_deviation(path, &s)); + } + } + } +} + /// The Simplify slider's levels: 1 pt at 50, doubled for every 10 levels down and halved for every /// 10 up; Auto-Simplify takes the lowest level whose tolerance is at most 1/400 of the size, and /// level 100 for a size under 12.5 pt. From 96d17d593cd5a486506b26624bcd7ef587bdbbee Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 08:33:30 -0800 Subject: [PATCH 31/49] Updates: take craft-update from craft-libs main at 6e03f65bf --- Cargo.lock | 5 +++-- Cargo.toml | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74b6189119..064b89e9e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1070,7 +1070,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "chrono", "dunce", @@ -1080,6 +1080,7 @@ dependencies = [ "image", "log", "plist", + "rustix 1.1.6", "semver", "serde", "serde_json", @@ -1096,7 +1097,7 @@ dependencies = [ [[package]] name = "craft-vectorcraft-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "flate2", "serde", diff --git a/Cargo.toml b/Cargo.toml index 2d53acdc39..6d47f057c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,9 +21,9 @@ craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637 # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] } # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. -craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" } +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } From 289401343ce131ed506d12435bf81627cf56cfa3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 08:35:19 -0800 Subject: [PATCH 32/49] Script checker: names follow Unicode ID_Start and ID_Continue Check Script read names with Rust's alphabetic and alphanumeric tests, so it split names at U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner, and took U+00B2 and U+2E2F into names. It now uses the Unicode 16.0 ID_Start and ID_Continue rule that vectorcraft-js uses, with `$` and `_` (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). A `\uXXXX` escape is part of a name only when it stands for a character the name may hold where it is: `\u0030abc` no longer becomes `0abc`, and malformed escapes end the name. The engine's lexer comparison test now checks that both crates take the same characters into names, written out and as escapes. --- Cargo.lock | 1 + .../tests/it/js_lexer_vs_scriptcheck.rs | 40 +++++- crates/scriptcheck/Cargo.toml | 3 + crates/scriptcheck/src/analyze.rs | 18 ++- crates/scriptcheck/src/lex.rs | 121 +++++++++++++++--- crates/scriptcheck/src/tests.rs | 93 ++++++++++++++ docs/target-app-parity.md | 1 + 7 files changed, 249 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74b6189119..e2ea95a8c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6725,6 +6725,7 @@ name = "vectorcraft-scriptcheck" version = "0.21.0" dependencies = [ "proptest", + "unicode-general-category", ] [[package]] diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs index 6c84c66b33..c9b5ac4665 100644 --- a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -1,8 +1,8 @@ //! The JavaScript parser's lexer (`vectorcraft-js`) and the script checker's (`vectorcraft-scriptcheck`) //! read the same scripts the same way: both crates stand alone, so each has its own lexer, and these //! tests run both over one set of scripts and fail on any disagreement about the lines a script has, -//! its preprocessor lines, and where a string, comment or regular expression that is never closed -//! starts. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which +//! its preprocessor lines, where a string, comment or regular expression that is never closed +//! starts, and the characters a name may hold. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which //! only the checker reads). use vectorcraft_js::{Options, TokenKind, parse, tokenize}; @@ -88,6 +88,42 @@ fn both_lexers_find_the_same_unclosed_token_on_the_same_line() { } } +/// Characters on both sides of the identifier rule: `Other_ID_Start`, `Other_ID_Continue`, connector +/// punctuation, marks, digits and letters of other scripts, and a superscript digit, a +/// `Pattern_Syntax` letter and punctuation that no name holds. +const NAME_CHARS: &[char] = &[ + '\u{AA}', '\u{B2}', '\u{B7}', '\u{D7}', '\u{300}', '\u{387}', '\u{660}', '\u{1369}', '\u{1885}', '\u{19DA}', '\u{200C}', '\u{200D}', '\u{203F}', + '\u{2040}', '\u{2118}', '\u{212E}', '\u{2160}', '\u{2E2F}', '\u{3005}', '\u{309B}', '\u{30FB}', '\u{FE33}', '\u{FF10}', '\u{FF65}', +]; + +/// Whether the parser reads `src` as one identifier spelling `name`. +fn one_identifier(src: &str, name: &str) -> bool { + let want = TokenKind::Ident(name.to_string()); + tokenize(src, &js_options()).is_ok_and(|tokens| tokens.iter().filter(|t| t.kind != TokenKind::Eof).map(|t| &t.kind).eq([&want])) +} + +/// Whether the checker lists the member `app.` as `app.`. +fn checker_member(src: &str, name: &str) -> bool { + let report = check("script.jsx", format!("app.{src};\n").as_bytes(), None, &Limits::default()); + report.members.iter().any(|m| m.name == format!("app.{name}")) +} + +#[test] +fn both_lexers_take_the_same_characters_into_names() { + assert!(checker_member("a\u{2118}b", "a\u{2118}b") && one_identifier("a\u{2118}b", "a\u{2118}b")); + for &c in NAME_CHARS { + let escape = format!("\\u{:04X}", u32::from(c)); + for (src, name) in [ + (format!("a{c}b"), format!("a{c}b")), + (format!("{c}b"), format!("{c}b")), + (format!("a{escape}b"), format!("a{c}b")), + (format!("{escape}b"), format!("{c}b")), + ] { + assert_eq!(checker_member(&src, &name), one_identifier(&src, &name), "{src:?} (U+{:04X})", u32::from(c)); + } + } +} + #[test] fn the_parser_reads_a_regexp_wherever_the_checker_does() { // Each `/` that divides has no second `/` after it on its line, so a lexer that read one as a diff --git a/crates/scriptcheck/Cargo.toml b/crates/scriptcheck/Cargo.toml index d0101be96a..2e9dac687c 100644 --- a/crates/scriptcheck/Cargo.toml +++ b/crates/scriptcheck/Cargo.toml @@ -9,5 +9,8 @@ rust-version.workspace = true [lints] workspace = true +[dependencies] +unicode-general-category = "1" + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/scriptcheck/src/analyze.rs b/crates/scriptcheck/src/analyze.rs index 1e5b21fc2b..9a637274df 100644 --- a/crates/scriptcheck/src/analyze.rs +++ b/crates/scriptcheck/src/analyze.rs @@ -116,8 +116,9 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str> } /// `src` with the `\uXXXX` escapes in its names written as the characters they stand for (ECMA-262 -/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character no -/// name may hold stays as written. Line breaks don't change, so every line keeps its number. +/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character a +/// name may not hold where the escape stands stays as written. Line breaks don't change, so every +/// line keeps its number. pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> Cow<'a, str> { match unescape_names(&src, acc) { Some(out) => Cow::Owned(out), @@ -145,20 +146,17 @@ fn unescape_names(src: &str, acc: &mut Acc) -> Option { changed.then_some(out) } -/// The name `text` with each `\uXXXX` escape for a character a name may hold written as that -/// character. +/// The name `text` with each `\uXXXX` escape for a character a name may hold where it stands written +/// as that character: a character a name may start with first, and one a name may hold after that. fn unescape_name(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(i) = rest.find("\\u") { out.push_str(rest.get(..i).unwrap_or_default()); let after = rest.get(i + 2..).unwrap_or_default(); - let c = after - .get(..4) - .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) - .and_then(|hex| u32::from_str_radix(hex, 16).ok()) - .and_then(char::from_u32) - .filter(|&c| crate::lex::ident_part(c)); + let first = out.is_empty(); + let c = crate::lex::escaped(rest.get(i..).unwrap_or_default()) + .filter(|&c| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) }); match c { Some(c) => { out.push(c); diff --git a/crates/scriptcheck/src/lex.rs b/crates/scriptcheck/src/lex.rs index be66dd4b1d..92d8c72e09 100644 --- a/crates/scriptcheck/src/lex.rs +++ b/crates/scriptcheck/src/lex.rs @@ -8,6 +8,10 @@ //! level and the levels of XML literals and their code are kept on stacks capped at the nesting //! limit. +use std::cmp::Ordering; + +use unicode_general_category::{GeneralCategory as G, get_general_category}; + use crate::catalog; use crate::report::{TOO_DEEP, UNTERMINATED_COMMENT, UNTERMINATED_REGEXP, UNTERMINATED_STRING, UNTERMINATED_TEMPLATE}; @@ -130,13 +134,105 @@ fn is_blank(c: char) -> bool { !is_break(c) && (c.is_whitespace() || c == '\u{feff}') } -fn ident_start(c: char) -> bool { - c == '$' || c == '_' || c.is_ascii_alphabetic() || (!c.is_ascii() && c.is_alphabetic()) +/// A character a name may start with: a Unicode `ID_Start` character, `$` or `_` (the Names and +/// Keywords clause of current ECMA-262 editions). `ID_Start` is the letters (categories Lu, Ll, Lt, +/// Lm, Lo and Nl) and `Other_ID_Start`, less `Pattern_Syntax` and `Pattern_White_Space` (Unicode +/// Standard Annex #31). The categories come from Unicode 16.0. +pub(crate) fn ident_start(c: char) -> bool { + match c { + '$' | '_' => true, + _ if c.is_ascii() => c.is_ascii_alphabetic(), + _ if is_other_id_start(c) => true, + _ => { + matches!( + get_general_category(c), + G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber + ) && !is_pattern_syntax(c) + } + } } -/// A character a name may hold after its first. +/// A character a name may hold after its first: a Unicode `ID_Continue` character or `$`. +/// `ID_Continue` adds to `ID_Start` the decimal digits (Nd), combining marks (Mn, Mc), connector +/// punctuation (Pc, which holds `_`) and `Other_ID_Continue` (which holds the zero width non-joiner +/// and joiner), less `Pattern_Syntax` and `Pattern_White_Space`. pub(crate) fn ident_part(c: char) -> bool { - ident_start(c) || c.is_ascii_digit() || (!c.is_ascii() && c.is_alphanumeric()) + match c { + '0'..='9' => true, + _ if ident_start(c) => true, + _ if c.is_ascii() => false, + _ if is_other_id_continue(c) => true, + _ => { + matches!(get_general_category(c), G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation) + && !is_pattern_syntax(c) + } + } +} + +/// The character the `\uXXXX` escape at the start of `s` stands for: `None` when `s` doesn't start +/// with `\u` and four hexadecimal digits, or when they give a lone surrogate. +pub(crate) fn escaped(s: &str) -> Option { + s.strip_prefix("\\u")? + .get(..4) + .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .and_then(char::from_u32) +} + +/// `Other_ID_Start` (Unicode PropList 16.0): characters kept in `ID_Start` though their category is +/// not a letter. +fn is_other_id_start(c: char) -> bool { + matches!(c, '\u{1885}' | '\u{1886}' | '\u{2118}' | '\u{212E}' | '\u{309B}' | '\u{309C}') +} + +/// `Other_ID_Continue` (Unicode PropList 16.0): characters kept in `ID_Continue` though their +/// category is not a letter, mark, digit or connector. +fn is_other_id_continue(c: char) -> bool { + matches!(c, '\u{B7}' | '\u{387}' | '\u{1369}'..='\u{1371}' | '\u{19DA}' | '\u{200C}' | '\u{200D}' | '\u{30FB}' | '\u{FF65}') +} + +/// Non-ASCII `Pattern_Syntax` ranges (Unicode PropList). Unicode does not change this set, and no +/// identifier character belongs to it. `Pattern_White_Space` outside ASCII (U+0085, U+200E, U+200F, +/// U+2028 and U+2029) holds no letter, mark, digit or connector, so the category checks leave it out. +const PATTERN_SYNTAX: [(char, char); 23] = [ + ('\u{A1}', '\u{A7}'), + ('\u{A9}', '\u{A9}'), + ('\u{AB}', '\u{AC}'), + ('\u{AE}', '\u{AE}'), + ('\u{B0}', '\u{B1}'), + ('\u{B6}', '\u{B6}'), + ('\u{BB}', '\u{BB}'), + ('\u{BF}', '\u{BF}'), + ('\u{D7}', '\u{D7}'), + ('\u{F7}', '\u{F7}'), + ('\u{2010}', '\u{2027}'), + ('\u{2030}', '\u{203E}'), + ('\u{2041}', '\u{2053}'), + ('\u{2055}', '\u{205E}'), + ('\u{2190}', '\u{245F}'), + ('\u{2500}', '\u{2775}'), + ('\u{2794}', '\u{2BFF}'), + ('\u{2E00}', '\u{2E7F}'), + ('\u{3001}', '\u{3003}'), + ('\u{3008}', '\u{3020}'), + ('\u{3030}', '\u{3030}'), + ('\u{FD3E}', '\u{FD3F}'), + ('\u{FE45}', '\u{FE46}'), +]; + +/// Whether a non-ASCII character is `Pattern_Syntax`. +fn is_pattern_syntax(c: char) -> bool { + PATTERN_SYNTAX + .binary_search_by(|&(lo, hi)| { + if hi < c { + Ordering::Less + } else if lo > c { + Ordering::Greater + } else { + Ordering::Equal + } + }) + .is_ok() } /// After `<`, the start of an XML literal: a name, `>` (a list), `!` (a comment or CDATA), `?` (a @@ -300,20 +396,13 @@ impl<'s> Lexer<'s> { } } - /// The rest of a name (its first character included): letters, digits, `$`, `_` and `\u` - /// escapes. + /// The rest of a name (its first character included): the characters [`ident_part`] allows, + /// written out or as `\uXXXX` escapes. An escape for any other character, or one without four + /// hexadecimal digits, ends the name. fn name(&mut self) { loop { match self.peek() { - Some('\\') if self.peek_at(1) == Some('u') => { - self.skip(2); - for _ in 0..4 { - match self.peek() { - Some(h) if h.is_ascii_hexdigit() => self.bump(h), - _ => break, - } - } - } + Some('\\') if escaped(self.rest()).is_some_and(ident_part) => self.skip(6), Some(c) if ident_part(c) => self.bump(c), _ => break, } @@ -820,7 +909,7 @@ impl<'s> Iterator for Lexer<'s> { self.close_level(); self.bump('}'); return Some(self.markup_token(start, line, T::XmlMore, stmt, expr)); - } else if ident_start(c) || (c == '\\' && self.peek_at(1) == Some('u')) { + } else if ident_start(c) || (c == '\\' && escaped(self.rest()).is_some_and(ident_start)) { self.name(); T::Ident } else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) { diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index e7ead63a4f..c7eed0c1a6 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -268,6 +268,99 @@ fn names_written_with_escapes_are_read_as_the_names_they_spell() { assert_eq!(r.lines, 3); } +/// `src` with the escapes in its names written as the characters they stand for. +fn unescaped(src: &str) -> String { + let limits = Limits::default(); + crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).into_owned() +} + +#[test] +fn names_are_made_of_the_unicode_identifier_characters() { + // U+2118 (Other_ID_Start) starts a name, and U+203F (connector punctuation), U+00B7 + // (Other_ID_Continue) and the zero width non-joiner and joiner continue one. + for name in ["\u{2118}a", "a\u{203F}b", "a\u{B7}b", "a\u{200C}b", "a\u{200D}b", "_\u{2118}", "$_a1", "\u{E9}t\u{E9}"] { + assert_eq!(tokens(name), [(T::Ident, name)], "{name:?}"); + } + for (src, want) in [ + // Characters that only continue a name, and digits, don't start one. + ("\u{203F}a", [(T::Other, "\u{203F}"), (T::Ident, "a")]), + ("\u{B7}a", [(T::Other, "\u{B7}"), (T::Ident, "a")]), + ("\u{200C}a", [(T::Other, "\u{200C}"), (T::Ident, "a")]), + ("0a", [(T::Number, "0"), (T::Ident, "a")]), + // A superscript digit (category No) and a letter that is Pattern_Syntax are in no name. + ("a\u{B2}", [(T::Ident, "a"), (T::Other, "\u{B2}")]), + ("a\u{2E2F}", [(T::Ident, "a"), (T::Other, "\u{2E2F}")]), + ("\u{2E2F}a", [(T::Other, "\u{2E2F}"), (T::Ident, "a")]), + // `#` and a name away from the start of a line, and a regular expression's flags. + ("a #\u{2118}b", [(T::Ident, "a"), (T::Other, "#\u{2118}b")]), + ("=/a/g\u{B2}", [(T::RegExp, "/a/g"), (T::Other, "\u{B2}")]), + ] { + let mut got = tokens(src); + if src.starts_with('=') { + got.remove(0); + } + assert_eq!(got, want, "{src:?}"); + } + assert_eq!(tokens("a #\u{B2}"), [(T::Ident, "a"), (T::Other, "#"), (T::Other, "\u{B2}")]); + assert_eq!(tokens("=/a/g\u{200D}"), [(T::Punct, "="), (T::RegExp, "/a/g\u{200D}")]); + // The code in an XML literal's braces reads names by the same rule. + assert!(tokens("var x = {\u{2118}b + c\u{203F}d};").iter().filter(|t| t.0 == T::Ident).any(|t| t.1 == "c\u{203F}d")); + assert!(tokens("var x = {\u{2118}b};").contains(&(T::Ident, "\u{2118}b"))); +} + +#[test] +fn an_escape_stands_for_a_character_only_where_a_name_may_hold_it() { + for (src, want) in [ + ("\\u2118a", "\u{2118}a"), + ("a\\u203Fb", "a\u{203F}b"), + ("a\\u00B7b", "a\u{B7}b"), + ("a\\u200Cb", "a\u{200C}b"), + ("a\\u200Db", "a\u{200D}b"), + ("a\\u0030", "a0"), + ("\\u0024\\u005F", "$_"), + // Not at the start of a name. + ("\\u0030abc", "\\u0030abc"), + ("\\u203Fa", "\\u203Fa"), + ("\\u00B7a", "\\u00B7a"), + // In no name. + ("a\\u00B2", "a\\u00B2"), + ("a\\u2E2F", "a\\u2E2F"), + ("\\u2E2F", "\\u2E2F"), + // Malformed escapes and a lone surrogate. + ("\\u", "\\u"), + ("a\\u12;", "a\\u12;"), + ("a\\uZZZZ", "a\\uZZZZ"), + ("a\\uD800", "a\\uD800"), + ("", ""), + ] { + assert_eq!(unescaped(src), want, "{src:?}"); + } + let r = report("\\u0061pp.a\\u203Fb;\napp.\\u2118c;\n"); + let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(members, ["app.a\u{203F}b", "app.\u{2118}c"]); + // An escape a name may not hold where it stands, or a malformed one, is not part of a name. + for (src, want) in [ + ("\\u0030a", [(T::Other, "\\"), (T::Ident, "u0030a")]), + ("a\\u00B2", [(T::Ident, "a"), (T::Other, "\\")]), + ("a\\u12", [(T::Ident, "a"), (T::Other, "\\")]), + ("a\\uD800", [(T::Ident, "a"), (T::Other, "\\")]), + ] { + assert_eq!(tokens(src).get(..2), Some(&want[..]), "{src:?}"); + } + assert_eq!(tokens("a\\u0030\\u200C"), [(T::Ident, "a\\u0030\\u200C")]); + let r = report("app.\\u0030abc;\napp.a\\u00B2;\n"); + assert!(r.members.iter().all(|m| !m.name.contains('\\') && !m.name.contains("0abc")), "{:?}", r.members); +} + +#[test] +fn malformed_escapes_in_bulk_are_read_once_through() { + let src = format!("{}\n{}\nalert(1);\n", "\\u\\uD800\\u12\\u0030\\u2118".repeat(20_000), "a\u{203F}".repeat(20_000)); + let r = report(&src); + assert_eq!(r.lines, 3); + assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[3][..])); + assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len()); +} + #[test] fn the_scripts_own_platform_names_are_not_the_platforms() { let r = report(concat!( diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 07d626e5a4..2b26880359 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -745,6 +745,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. + - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 16.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. A `\uXXXX` escape is read as its character only where a name may hold it, so `0abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 16.0, so characters assigned in later Unicode versions are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_16_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. From b3860bf7d0a7c6efc15cf5cd878453cfa89f6d65 Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Wed, 11 Nov 2026 21:40:27 +0500 Subject: [PATCH 33/49] Commands: every angle and rotation param says which way it turns (#6340) The params docs now state the direction (and the zero) of each angle or rotation a command takes; behaviour is unchanged. Shape creation, text.create, the grids, add-on shapes, saved views, Liquify, Puppet Warp pins, Mirror and Radial Repeat and Vector Halftone turn clockwise; text character rotation, gradients, the eraser tip and print screen angles turn counter-clockwise. view.saved.new's rotation note changes its schema, so the API level goes to 3. A test walks every command's params doc and fails when a param named like an angle or rotation doesn't say clockwise or counter-clockwise. --- .../engine/src/cmd/addons/pathtools/shape.rs | 2 +- crates/engine/src/cmd/addons/shapes.rs | 12 +- crates/engine/src/cmd/brushsym.rs | 2 +- crates/engine/src/cmd/create.rs | 40 ++++-- crates/engine/src/cmd/distortcmds.rs | 6 +- crates/engine/src/cmd/draw2/commands.rs | 2 +- crates/engine/src/cmd/gradient.rs | 2 +- crates/engine/src/cmd/halftone.rs | 2 +- crates/engine/src/cmd/object/commands.rs | 4 +- crates/engine/src/cmd/paint.rs | 2 +- crates/engine/src/cmd/panelcmds.rs | 2 +- crates/engine/src/cmd/params.rs | 6 +- crates/engine/src/cmd/patterncmds.rs | 4 +- crates/engine/src/cmd/print.rs | 2 +- crates/engine/src/cmd/textedit.rs | 4 +- crates/engine/src/cmd/views.rs | 11 +- crates/engine/src/schemas.rs | 4 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/turn_direction.rs | 116 ++++++++++++++++++ crates/mcp/src/tools/definitions.rs | 6 +- docs/mcp.md | 10 +- 21 files changed, 192 insertions(+), 48 deletions(-) create mode 100644 crates/engine/src/tests/turn_direction.rs diff --git a/crates/engine/src/cmd/addons/pathtools/shape.rs b/crates/engine/src/cmd/addons/pathtools/shape.rs index 4c1ba7e2dd..fc88442997 100644 --- a/crates/engine/src/cmd/addons/pathtools/shape.rs +++ b/crates/engine/src/cmd/addons/pathtools/shape.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Edit Parametric Shape", [], None, - "{id, kind?, center?, width?, height?, rotation?, count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}", + "{id, kind?, center?, width?, height?, rotation?: degrees clockwise (0: upright), count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}", has_doc, set ), diff --git a/crates/engine/src/cmd/addons/shapes.rs b/crates/engine/src/cmd/addons/shapes.rs index c401733906..24d0e4c3ac 100644 --- a/crates/engine/src/cmd/addons/shapes.rs +++ b/crates/engine/src/cmd/addons/shapes.rs @@ -26,7 +26,7 @@ pub fn specs() -> Vec { "Rectangle", [], None, - "{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, rectangle ), @@ -35,7 +35,7 @@ pub fn specs() -> Vec { "Ellipse", [], None, - "{x, y, width, height, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{x, y, width, height, startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, ellipse ), @@ -44,7 +44,7 @@ pub fn specs() -> Vec { "Polygon", [], None, - "{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}", has_doc, polygon ), @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Star", [], None, - "{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}", has_doc, star ), @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "3D Box", [], None, - "{x, y, width, height, angleX=30, angleY=30, angleZ=0, parallelX=false, parallelY=true, parallelZ=false} → {id}", + "{x, y, width, height, angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}", has_doc, box3d ), @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "Configure Shape", [], None, - "{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?, angleZ?, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}", + "{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}", has_selection, configure ), diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 85c1412f0c..1677d27f91 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 407b20a1ae..973c82af53 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -17,19 +17,43 @@ pub fn specs() -> Vec { "Rectangle", [], None, - "{x, y, width, height, radius?: pt, rotation?: deg (about centre)} → {id}", + "{x, y, width, height, radius?: pt, rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, rectangle ), - cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height, rotation?: deg (about centre)} → {id}", has_doc, ellipse), - cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon), - cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star), + cmd!( + "shape.ellipse", + "Ellipse", + [], + None, + "{x, y, width, height, rotation?: deg clockwise about the centre (0: upright)} → {id}", + has_doc, + ellipse + ), + cmd!( + "shape.polygon", + "Polygon", + [], + None, + "{cx, cy, radius, sides=6, rotation?: deg clockwise (0: a corner straight up)} → {id}", + has_doc, + polygon + ), + cmd!( + "shape.star", + "Star", + [], + None, + "{cx, cy, radius1, radius2, points=5, rotation?: deg clockwise (0: a point straight up)} → {id}", + has_doc, + star + ), cmd!( "shape.flare", "Flare", [], None, - "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg), seed?} → {id}", + "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?} → {id}", has_doc, flare ), @@ -49,7 +73,7 @@ pub fn specs() -> Vec { "Rectangular Grid", [], None, - "{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}", + "{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, rect_grid ), @@ -58,7 +82,7 @@ pub fn specs() -> Vec { "Polar Grid", [], None, - "{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}", + "{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, polar_grid ), @@ -85,7 +109,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index bcaa59426b..17638477a9 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Liquify", [], None, - "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}", + "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle? (deg clockwise: the brush's width axis from level), intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °, positive turns clockwise), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}", has_doc, liquify ), @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "Puppet Warp", [], None, - "{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)", + "{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, clockwise, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)", has_doc, puppet_warp ), @@ -89,7 +89,7 @@ pub fn specs() -> Vec { "Puppet Warp Pins", [], None, - "{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp", + "{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg clockwise, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp", has_doc, puppet_pins ), diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 1d2f006f1d..8946aa4351 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Eraser", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", + "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", has_doc, erase_region ), diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index c73a23d6c3..83b08db0c8 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear or radial, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear or radial, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right), aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), diff --git a/crates/engine/src/cmd/halftone.rs b/crates/engine/src/cmd/halftone.rs index bd6afd4663..0c1d36d35d 100644 --- a/crates/engine/src/cmd/halftone.rs +++ b/crates/engine/src/cmd/halftone.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Vector Halftone…", ["Object"], None, - "{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"; #rrggbbaa and #rgba are refused), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg clockwise (45; 0: level rows of dots; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"; #rrggbbaa and #rgba are refused), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", has_selection, halftone )] diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 2f0aa742d1..eb019a88e9 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -61,7 +61,7 @@ pub fn specs() -> Vec { "Shear…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), angle: deg (positive turns the vertical edges clockwise with the horizontal axis, the top to the right; the horizontal edges clockwise with the vertical axis, the right side down), axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, shear ), @@ -297,7 +297,7 @@ pub fn specs() -> Vec { "Live Shape Properties", [], None, - "{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", + "{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counter-clockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counter-clockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", has_selection, set_live_shape ), diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 4cc4bbc2e4..19bd30e69a 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 9f50507aff..e8f1ccf277 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index c9f51e0654..4b8ae5d2be 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -51,7 +51,7 @@ pub struct ParamKeys<'a> { /// The text between the braces of the object `doc` starts with and what follows it, or why it /// has none. -fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> { +pub(crate) fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> { // (Named, not written in the `Err`: these are for the tests of the descriptions, not messages // the app shows, which the message catalogs would have to translate.) let (no_object, not_closed) = ("the description doesn't start with the params object `{…}`", "the params object `{…}` isn't closed"); @@ -78,7 +78,7 @@ fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> { } /// `s` split at each `sep` that is outside brackets, quotes and backticks. -fn split_top(s: &str, sep: char) -> Vec<&str> { +pub(crate) fn split_top(s: &str, sep: char) -> Vec<&str> { let (mut out, mut depth, mut quote, mut start) = (Vec::new(), 0usize, None, 0); for (i, c) in s.char_indices() { match (quote, c) { @@ -98,7 +98,7 @@ fn split_top(s: &str, sep: char) -> Vec<&str> { out } -fn is_key(s: &str) -> bool { +pub(crate) fn is_key(s: &str) -> bool { let mut chars = s.chars(); chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index ef7820b71f..3ea97434a0 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -116,7 +116,7 @@ pub fn specs() -> Vec { "Mirror", ["Object", "Repeat"], None, - "{ids?, angle?: deg (90 = vertical axis), offset?: pt (10)} mirror repeat of the selection → {id}", + "{ids?, angle?: deg clockwise from level (the mirror axis; 90 = vertical), offset?: pt (10)} mirror repeat of the selection → {id}", has_selection, repeat_mirror ), @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Options…", ["Object", "Repeat"], None, - "{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, brickOffset?, flipRows?, flipCols? | angle?} set the options of the selected repeats", + "{instances?, radius?, reverseOverlap?, startAngle?, endAngle?: deg clockwise (0: where the source art sits), center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, brickOffset?, flipRows?, flipCols? | angle?: deg clockwise from level (the mirror axis; 90 = vertical)} set the options of the selected repeats", has_repeat, repeat_options ), diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 76e09641f9..9fe4665a5c 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Print Setup", [], None, - "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", + "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (screen angle, degrees counter-clockwise as PostScript setscreen takes it; default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", has_doc, setup ), diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 79dcee7135..3d1e0edd85 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -49,7 +49,7 @@ pub fn specs() -> Vec { "Touch Type", [], None, - "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)", + "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright)} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)", has_doc, touch_type ), diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs index f4db493d66..560da3942c 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -23,7 +23,9 @@ static NEW_VIEW: ParamSchema = ParamSchema::new( Param::new("name", Type::String).default_note("the first free \"View N\"").null_omitted(), Param::new("center", Type::Point), Param::new("zoom", Type::Number { min: Some(Bound::Exclusive(0.0)), max: None, unit: None }).doc("1 = 100%"), - Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }).defaults_to("0"), + Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }) + .defaults_to("0") + .doc("clockwise on screen, as view.rotate turns the view"), ], "save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once)", ) @@ -46,7 +48,8 @@ static EDIT_VIEWS: ParamSchema = ParamSchema::new( "rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}", ); -static LIST_VIEWS: ParamSchema = ParamSchema::new(&[], "").returns("{views: [{name, center, zoom, rotation}]}").writes(Writes::Nothing); +static LIST_VIEWS: ParamSchema = + ParamSchema::new(&[], "").returns("{views: [{name, center, zoom, rotation (degrees clockwise)}]}").writes(Writes::Nothing); static TRANSPARENCY_GRID: ParamSchema = ParamSchema::new( &[Param::new("on", Type::Boolean).default_note("toggle").null_omitted()], @@ -86,7 +89,7 @@ pub fn specs() -> Vec { "New View…", ["View"], None, - "{name?: string (default: the first free \"View N\"), center: [x, y], zoom: number > 0 (1 = 100%), rotation?: number deg (default: 0)} save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once) → {name, index}", + "{name?: string (default: the first free \"View N\"), center: [x, y], zoom: number > 0 (1 = 100%), rotation?: number deg (default: 0) (clockwise on screen, as view.rotate turns the view)} save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once) → {name, index}", has_doc, new_view, schema: &NEW_VIEW @@ -101,7 +104,7 @@ pub fn specs() -> Vec { edit_views, schema: &EDIT_VIEWS ), - cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views, schema: &LIST_VIEWS), + cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation (degrees clockwise)}]}", has_doc, list_views, schema: &LIST_VIEWS), cmd!( query "view.transparencyGrid", "Show Transparency Grid", diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index 61ec8f0fd6..fb08bb649c 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -26,10 +26,10 @@ use crate::cmd::{CommandSpec, bad, command_specs}; use crate::{EngineError, Result}; /// The API level of the commands' parameters. -pub const API_VERSION: u32 = 2; +pub const API_VERSION: u32 = 3; /// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s. -pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653)]; +pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653), (3, 0xffe0_58a3_2f0c_aa2a)]; /// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]). pub fn schema_hash() -> u64 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9c6de6a89e..6a57cd63d3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -192,6 +192,7 @@ mod toolsettings; mod transformfx; mod transparencygrid; mod trap; +mod turn_direction; mod typearea; mod typeglyphbounds; mod typescale; diff --git a/crates/engine/src/tests/turn_direction.rs b/crates/engine/src/tests/turn_direction.rs new file mode 100644 index 0000000000..f12f0d1962 --- /dev/null +++ b/crates/engine/src/tests/turn_direction.rs @@ -0,0 +1,116 @@ +//! Every angle or rotation a command takes says which way it turns (#6340): creation commands turn +//! clockwise and most edits counter-clockwise, so a client can't guess. + +use crate::cmd::params::{is_key, object_body, split_top}; +use crate::command_specs; + +/// Params that are angles but not a turn, with why. +const NOT_A_TURN: &[(&str, &str, &str)] = &[ + ("addon.pathtools.cornerSet", "maxAngle", "the angle a corner's sides meet at"), + ("addon.pathtools.cornerSet", "minAngle", "the angle a corner's sides meet at"), + ("addon.shapes.box3d", "angleX", "a tilt out of the page: the description says which side turns away"), + ("addon.shapes.box3d", "angleY", "a tilt out of the page: the description says which side turns away"), + ("addon.shapes.configure", "angleX", "a tilt out of the page: the description says which side turns away"), + ("addon.shapes.configure", "angleY", "a tilt out of the page: the description says which side turns away"), + ("object.path.simplify", "cornerAngle", "the angle a corner's sides meet at"), + ("perspective.grid.define", "angle", "the viewing angle, 0 to 90°"), +]; + +/// A param name that is an angle or a turn. +fn is_turn(key: &str) -> bool { + let k = key.to_ascii_lowercase(); + k.ends_with("angle") || k.ends_with("angles") || k.starts_with("angle") || ["rotation", "rotate", "piestart", "pieend"].contains(&k.as_str()) +} + +/// The bodies of the `{…}` objects in `text` that are not inside another one. +fn objects_in(text: &str) -> Vec<&str> { + let mut out = vec![]; + let mut rest = text; + while let Some(i) = rest.find('{') { + let Ok((body, after)) = object_body(&rest[i..]) else { break }; + out.push(body); + rest = after; + } + out +} + +/// `(key, what the description says about it)` for each key the object `body` names, and for the +/// keys of the objects inside it. A key given without a note (`a?, b?: degrees`) shares the note of +/// the next key that has one. +fn keys_and_notes(body: &str, out: &mut Vec<(String, String)>) { + let mut items: Vec<(&str, String)> = vec![]; + for entry in split_top(body, ',').into_iter().map(str::trim).filter(|e| !e.is_empty() && !e.starts_with('…')) { + for (i, alternative) in split_top(entry, '|').into_iter().enumerate() { + let head = alternative.split([':', '=', '(']).next().unwrap_or_default(); + let note = alternative.get(head.len()..).unwrap_or_default(); + let name = head.trim().trim_end_matches('?').trim_end(); + let a_key = is_key(name) && (i == 0 || head.trim_end().ends_with('?') || note.starts_with(':')); + match items.last_mut() { + Some((_, last)) if !a_key => last.push_str(alternative), + _ if a_key => items.push((name, note.to_string())), + _ => {} + } + } + } + for (i, (key, note)) in items.iter().enumerate() { + let shared = items.get(i..).unwrap_or_default().iter().map(|(_, n)| n.trim()).find(|n| !n.is_empty()).unwrap_or_default(); + out.push((key.to_string(), shared.to_string())); + for inner in objects_in(note) { + keys_and_notes(inner, out); + } + } +} + +/// The turn params of description `doc` whose note doesn't say which way they turn. +fn undirected(doc: &str) -> Vec { + let mut found = vec![]; + let mut next = Some(doc); + // `{…} or {…}`: each object the command takes. + while let Some(text) = next { + let Ok((body, rest)) = object_body(text) else { break }; + keys_and_notes(body, &mut found); + next = rest.trim_start().strip_prefix("or ").or_else(|| rest.trim_start().strip_prefix("| ")).filter(|r| r.starts_with('{')); + } + found.into_iter().filter(|(k, note)| is_turn(k) && !note.to_ascii_lowercase().contains("clockwise")).map(|(k, _)| k).collect() +} + +#[test] +fn a_note_is_found_for_each_key() { + assert_eq!(undirected("{angle: deg (counter-clockwise), origin?}"), Vec::::new()); + assert_eq!(undirected("{x, rotation?: deg} → {rotation}"), ["rotation"]); + // A shared note, an alternative, a nested object, and the object after `or`. + assert_eq!(undirected("{inAngle?, outAngle?: degrees counter-clockwise, size?: pt}"), Vec::::new()); + assert_eq!(undirected("{startAngle?, endAngle?, center?: [x,y] | hSpacing? | angle?}"), ["startAngle", "endAngle", "angle"]); + assert_eq!(undirected("{gradient?: {kind?: linear|radial, angle?: deg}, id?}"), ["angle"]); + assert_eq!(undirected("{id} or {ids, rotate?: deg}"), ["rotate"]); + // Not a turn by its name, or a value rather than a key. + assert_eq!(undirected("{autoRotate: true, orientation?: portrait|landscape, mode?: rotation|scale}"), Vec::::new()); +} + +/// Each command param that is an angle or a rotation says in its description which way it turns +/// (clockwise or counter-clockwise), unless [`NOT_A_TURN`] says why it isn't a turn. +#[test] +fn every_turn_param_says_which_way_it_turns() { + let mut missing = vec![]; + for c in command_specs() { + for key in undirected(c.params) { + if !NOT_A_TURN.iter().any(|(id, k, _)| *id == c.id && *k == key) { + missing.push(format!("{}: {key}", c.id)); + } + } + } + assert!(missing.is_empty(), "{} params don't say whether they turn clockwise or counter-clockwise:\n{}", missing.len(), missing.join("\n")); + let stale: Vec<_> = NOT_A_TURN + .iter() + .filter(|(id, key, _)| !command_specs().iter().any(|c| c.id == *id && keys_named(c.params).iter().any(|k| k == key))) + .collect(); + assert!(stale.is_empty(), "NOT_A_TURN names params no command takes: {stale:?}"); +} + +fn keys_named(doc: &str) -> Vec { + let mut found = vec![]; + if let Ok((body, _)) = object_body(doc) { + keys_and_notes(body, &mut found); + } + found.into_iter().map(|(k, _)| k).collect() +} diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 27d92394ff..9d92942dd2 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -259,7 +259,7 @@ pub fn tool_definitions() -> Vec { "radius1": num("Star outer radius"), "radius2": num("Star inner radius"), "points": {"type": "integer", "minimum": 3, "description": "Star points (default 5)"}, - "rotation": num("Rotation in degrees (polygon/star)"), + "rotation": num("Rotation in degrees clockwise (polygon/star; 0: a corner or point straight up)"), "x1": num("Line start x"), "y1": num("Line start y"), "x2": num("Line end x"), @@ -471,12 +471,12 @@ pub fn tool_definitions() -> Vec { "ids": {"type": "array", "items": {"type": "integer"}}, "dx": num("Move right"), "dy": num("Move down"), - "rotate": num("Rotation in degrees"), + "rotate": num("Rotation in degrees counter-clockwise"), "scale": num("Uniform scale in %"), "scaleX": num("Horizontal scale in %"), "scaleY": num("Vertical scale in %"), "reflect": {"type": "string", "enum": ["vertical", "horizontal"]}, - "shear": num("Shear angle in degrees (horizontal axis)"), + "shear": num("Shear angle in degrees (horizontal axis: positive moves the top to the right, turning the vertical edges clockwise)"), "origin": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "copy": {"type": "boolean"}, }), diff --git a/docs/mcp.md b/docs/mcp.md index 7d66c7c0b3..dece51e0e0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -459,7 +459,7 @@ opens the picker through `run_command` with the remote backend. | `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | | `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | -| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | +| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. Polygons and stars take `rotation` in degrees clockwise (0: a corner or point straight up). `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc; `f`, `v`, `x` and `c` while dragging out a rectangular or polar grid). | | `type_text` | `{text}` | Remote only. | @@ -473,7 +473,7 @@ opens the picker through `run_command` with the remote backend. | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Successful panel operations, direct `object.pathfinder.*` commands, and `command.batch` steps (including Actions panel playback) set its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits, live effects and `action.batch` do not set history. With no history it returns "no Pathfinder operation applied yet". | -| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | +| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. `rotate` is in degrees counter-clockwise; a positive `shear` moves the top to the right. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | | `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | @@ -1462,9 +1462,9 @@ path to a value inside a list or an object, and the document and its undo histor An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests -more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 2 +more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 3 (`vectorcraft_engine::schemas::API_VERSION`); a change to any of them raises the level. Level 2 added -`engine.apiReference`. Commands that plug-ins add have no schema here. +`engine.apiReference`; level 3 says that `rotation` in `view.saved.new` and `view.saved.list` turns clockwise. Commands that plug-ins add have no schema here. `engine.apiReference {}` → `{markdown}` returns the command API reference, written from these schemas each time it runs; no file of it is kept. It needs no open document and changes nothing. The reference has a `##` section for each @@ -2147,7 +2147,7 @@ repeated names, and ids the document doesn't have are dropped. View → New View… keeps a view under a name, in the document: `view.saved.new {name?, center: [x, y], zoom, rotation?}` (the desktop app fills in its current view; default the first free "View N"; at most 25 per document, -names up to 255 characters, each name once) → `{name, index}`, one undo step. `view.saved.list` → +names up to 255 characters, each name once; `rotation` is in degrees clockwise on screen, as `view.rotate` turns the view) → `{name, index}`, one undo step. `view.saved.list` → `{views: [{name, center, zoom, rotation}…]}` in menu order. Edit Views… renames and deletes: `view.saved.edit {name, newName?, delete?}`, or several at once as `{edits: [{name, newName?, delete?}…]}` in one undo step (→ `{views}`), where every `name` is the name before the edit and the names must stay unique; it is From ec9169591a4bd176d11982575eae76cf8d67d60c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:42:19 +0000 Subject: [PATCH 34/49] Links: groups of one file share one decoded image per operation --- crates/engine/src/cmd/fileio/load.rs | 1 + crates/engine/src/cmd/links.rs | 59 +++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 9b3223829f..1b217e6c2d 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -63,6 +63,7 @@ impl Loaded { /// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG /// (what browsers, PDF and SVG viewers show). +#[derive(Clone)] pub struct RasterImage { /// Content key for `Document::images` (identical files share one blob). pub key: String, diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 22d61723e9..020b483f3a 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -521,10 +521,25 @@ fn linked_image(bytes: &[u8]) -> Result { /// The file read for a link: the link to it and its image. type FileRead = (LinkInfo, RasterImage); -/// The file at `f` read for a link like `base` (its image decoded). -fn read_image(f: &mut Found, base: &LinkInfo) -> Result { +/// The images read in one operation, by the path of the file and its content hash: groups whose +/// image keys differ but whose file is the same share one decoded image (and its encoded bytes) +/// instead of each keeping a copy. +type ImageReads = BTreeMap; + +/// The file at `f` read for a link like `base` (its image decoded, or the one `reads` holds for +/// the same file content). +fn read_image(f: &mut Found, base: &LinkInfo, reads: &mut ImageReads) -> Result { let bytes = f.read()?; - Ok((f.link(&bytes, base), linked_image(&bytes)?)) + let hash = hash_bytes(&bytes); + let img = match reads.get(&f.path) { + Some((h, img)) if *h == hash => img.clone(), + _ => { + let img = linked_image(&bytes)?; + reads.insert(f.path.clone(), (hash.clone(), img.clone())); + img + } + }; + Ok((f.link_hashed(hash, base), img)) } /// A linked file read for the objects that show it. @@ -536,8 +551,8 @@ enum Read { } /// The file at `f` read for group `g`: its image, or the artboard or page `g` shows. -fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result { - let Some((bounding, page_box)) = g.document else { return read_image(f, &g.link).map(Read::Image) }; +fn read_for(f: &mut Found, g: &Group, cmd: &str, reads: &mut ImageReads) -> Result { + let Some((bounding, page_box)) = g.document else { return read_image(f, &g.link, reads).map(Read::Image) }; let bytes = f.read()?; let page = g.link.page.unwrap_or(1); let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, page_box, cmd)?; @@ -922,6 +937,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps // (the path as it was, the content shown, a placed document?, the link now) of each file found // away from its path or read again. let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![]; + let mut image_reads = ImageReads::new(); for g in groups(d, None) { let row = rows(std::iter::once(&g), base.doc); let shows_preview = match g.document { @@ -934,7 +950,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps (Status::Missing, _) => out.missing.extend(row), (Status::Modified, Some(mut f)) if update || unread => { let force = unread.then_some(Preserve::Bounds); - match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, force, Some(&g))) { + match read_for(&mut f, &g, "document.open", &mut image_reads).and_then(|read| install_read(d, &g.ids, read, force, Some(&g))) { Ok(()) if unread => {} Ok(()) => out.updated.extend(row), Err(_) => out.missing.extend(row), @@ -943,7 +959,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps (Status::Modified, _) => out.modified.extend(row), // A placed document keeps its file's bytes: only lost ones are read again. (Status::Ok, Some(mut f)) if g.document.is_some() => { - let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None }; + let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open", &mut image_reads)) } else { None }; match read { Some(Ok(read)) => { if install_read(d, &g.ids, read, Some(Preserve::Bounds), Some(&g)).is_err() { @@ -1196,6 +1212,7 @@ fn update(s: &mut Session, p: &Value) -> Result { fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) -> Result { let st = s.doc()?; let (mut reads, mut missing) = (vec![], vec![]); + let mut image_reads = ImageReads::new(); let asked = ids.is_some() || path.is_some(); for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) { match probe(&g, LinkBase::of(st)) { @@ -1203,7 +1220,7 @@ fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) - // Unchanged and showing the file: nothing to read unless asked for. (Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {} // Unreadable as an image (or a document) counts as missing. - (_, Some(mut f)) => match read_for(&mut f, &g, "links.update") { + (_, Some(mut f)) => match read_for(&mut f, &g, "links.update", &mut image_reads) { Ok(read) => reads.push((g, read)), Err(_) => missing.extend(ids_json(&g.ids)), }, @@ -1706,13 +1723,14 @@ fn embed(s: &mut Session, p: &Value) -> Result { let ids: Vec = ids.into_iter().filter(|id| matches!(kind(id), Some(NodeKind::Image(_)))).collect(); // Images showing their preview take their file's pixels first. let (mut done, mut reads) = (vec![], vec![]); + let mut image_reads = ImageReads::new(); for g in groups(&st.doc, Some(&ids)) { if !previewing(&st.doc, &g.key) { done.extend(g.ids); continue; } match probe(&g, LinkBase::of(st)) { - (Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) { + (Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link, &mut image_reads) { Ok(read) => { done.extend(&g.ids); reads.push((g.ids, read)); @@ -1921,3 +1939,26 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { set(&mut shown); Ok(json!({ "ids": ids_json(&ids), "placement": placement_json(shown) })) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::tests::links::{Folder, RED, png, write}; + + #[test] + fn groups_of_one_file_share_one_decoded_image() { + let dir = Folder::new("shared-read"); + let path = dir.file("pic.png"); + write(&path, &png(40, 20, RED)); + let (mut reads, base) = (ImageReads::new(), LinkInfo::new(path.clone())); + let mut read = || read_image(&mut found_at(path.clone()).unwrap(), &base, &mut reads).unwrap().1; + let (a, b) = (read(), read()); + assert!(Arc::ptr_eq(&a.blob.bytes, &b.blob.bytes), "one retained copy of the bytes"); + // A changed file is decoded again. + write(&path, &png(40, 20, [1, 2, 3])); + let c = read(); + assert!(!Arc::ptr_eq(&a.blob.bytes, &c.blob.bytes)); + } +} From 4da8e7e8c78795194c8176ec02c0819b02d5d7da Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 08:50:42 -0800 Subject: [PATCH 35/49] Swatches: default names read a -0 component as 0 `color_name` clamps each component before rounding, but `clamp` keeps a -0.0, so a color with a -0.0 component was named "R=-0 G=128 B=-0" (and "K=-0", "Gray K=-0" in CMYK and Gray). Adding 0.0 after rounding turns it into 0, as the Lab branch already does. --- crates/doc/src/swatches.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 5aa6147fa9..80db3d923a 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -195,9 +195,10 @@ impl Document { /// The default name of a solid swatch: its values in its own colour model ("C=10 M=20 Y=30 K=0", /// "R=255 G=128 B=0", "Gray K=40", "L=52 a=70 b=-30"). pub fn color_name(c: Color) -> String { - // Clamped first, so a conversion's tiny negative never reads "-0". - let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round(); - let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round(); + // Clamped first, so a conversion's tiny negative never reads "-0". `clamp` keeps a -0.0, so + // `+ 0.0` turns it into 0. + let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round() + 0.0; + let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() + 0.0; match c { Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)), @@ -493,6 +494,13 @@ mod tests { assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0"); } + #[test] + fn default_names_read_negative_zero_as_zero() { + assert_eq!(color_name(Color::Rgb { r: -0.0, g: 0.5, b: -0.0 }), "R=0 G=128 B=0"); + assert_eq!(color_name(Color::Cmyk { c: -0.0, m: 0.2, y: 0.3, k: -0.0 }), "C=0 M=20 Y=30 K=0"); + assert_eq!(color_name(Color::Gray { k: -0.0 }), "Gray K=0"); + } + #[test] fn free_swatch_names_match_naming_each_swatch_as_it_is_added() { let (mut d, _, _) = doc(); From f07c2f7e9c930ed8a5f0842c9632cbde52d848b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Wed, 11 Nov 2026 17:08:39 +0000 Subject: [PATCH 36/49] G5: built-in Skill Icons symbol library Five symbol libraries; MCP docs list Skill Icons with the other generated symbol libraries. --- crates/engine/src/cmd/symbollib.rs | 46 +++++++++++++++++++ crates/engine/src/tests/symbollib.rs | 32 ++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 6 +++ crates/ui-egui/src/i18n/bs.tsv | 6 +++ crates/ui-egui/src/i18n/ca.tsv | 6 +++ crates/ui-egui/src/i18n/cs.tsv | 6 +++ crates/ui-egui/src/i18n/da.tsv | 6 +++ crates/ui-egui/src/i18n/de.tsv | 6 +++ crates/ui-egui/src/i18n/el.tsv | 6 +++ crates/ui-egui/src/i18n/es.tsv | 6 +++ crates/ui-egui/src/i18n/fi.tsv | 6 +++ crates/ui-egui/src/i18n/fr.tsv | 6 +++ crates/ui-egui/src/i18n/hi.tsv | 6 +++ crates/ui-egui/src/i18n/hu.tsv | 6 +++ crates/ui-egui/src/i18n/id.tsv | 6 +++ crates/ui-egui/src/i18n/it.tsv | 6 +++ crates/ui-egui/src/i18n/ja.tsv | 6 +++ crates/ui-egui/src/i18n/ka.tsv | 6 +++ crates/ui-egui/src/i18n/ko.tsv | 6 +++ crates/ui-egui/src/i18n/nl.tsv | 6 +++ crates/ui-egui/src/i18n/pl.tsv | 6 +++ crates/ui-egui/src/i18n/pt-br.tsv | 6 +++ crates/ui-egui/src/i18n/ru.tsv | 6 +++ crates/ui-egui/src/i18n/sv.tsv | 6 +++ crates/ui-egui/src/i18n/tr.tsv | 6 +++ crates/ui-egui/src/i18n/uk.tsv | 6 +++ crates/ui-egui/src/i18n/vi.tsv | 6 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 6 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 6 +++ .../src/panels/tests_symbol_libraries.rs | 2 +- docs/gaps.md | 14 +++--- docs/mcp.md | 4 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 13 +++--- docs/ui-parity.md | 2 +- 35 files changed, 261 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index 02767aef39..b8cae195c0 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -159,6 +159,7 @@ pub const BUILTIN: &[BuiltinSymbolLibrary] = &[ BuiltinSymbolLibrary { id: "ui-frames", name: "UI Frames", make: ui_frames }, BuiltinSymbolLibrary { id: "map-markers", name: "Map Markers", make: map_markers }, BuiltinSymbolLibrary { id: "inventory-icons", name: "Inventory Icons", make: inventory_icons }, + BuiltinSymbolLibrary { id: "skill-icons", name: "Skill Icons", make: skill_icons }, ]; /// Built-in library `id`, made once. @@ -312,6 +313,51 @@ fn inventory_icons() -> SymbolLibrary { SymbolLibrary { name: "Inventory Icons".into(), doc } } +/// Skill Icons: attack, defend, heal, buff and ult marks for RPG/UI art (original, no 9-slice). +fn skill_icons() -> SymbolLibrary { + let c = Point::ZERO; + let attack = [(-18.0, 14.0), (-6.0, 2.0), (-2.0, 6.0), (18.0, -14.0), (14.0, -18.0), (-6.0, -2.0), (-10.0, 2.0), (-18.0, 6.0)]; + let defend = [(0.0, -18.0), (14.0, -10.0), (12.0, 6.0), (0.0, 18.0), (-12.0, 6.0), (-14.0, -10.0)]; + let heal = [ + (-4.0, -18.0), + (4.0, -18.0), + (4.0, -4.0), + (18.0, -4.0), + (18.0, 4.0), + (4.0, 4.0), + (4.0, 18.0), + (-4.0, 18.0), + (-4.0, 4.0), + (-18.0, 4.0), + (-18.0, -4.0), + (-4.0, -4.0), + ]; + let buff = [(0.0, -18.0), (10.0, -2.0), (4.0, -2.0), (4.0, 18.0), (-4.0, 18.0), (-4.0, -2.0), (-10.0, -2.0)]; + let attack: Vec = attack.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let defend: Vec = defend.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let heal: Vec = heal.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let buff: Vec = buff.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let icons = [ + ("Attack", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&attack, true)), "#e74c3c"), + ("Defend", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&defend, true)), "#3498db"), + ("Heal", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&heal, true)), "#2ecc71"), + ("Buff", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&buff, true)), "#f1c40f"), + ("Ult", shapes::star(c, 18.0, 7.0, 8, 0.0), "#9b59b6"), + ]; + let mut doc = Document::new(100.0, 100.0); + for (name, path, hex) in icons { + let fill = Color::from_hex(hex).unwrap_or(Color::BLACK); + let edge = fill.lerp(&Color::BLACK, 0.35); + let id = doc.alloc_id(); + let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25)); + let Ok((art, size, _)) = normalize(art) else { continue }; + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + set_natural_size(&mut doc, name, Some(size)); + } + doc.title = "Skill Icons".into(); + SymbolLibrary { name: "Skill Icons".into(), doc } +} + /// An instance of symbol `name` (what the resource gathering follows). fn instance(name: &str) -> Node { Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY }) diff --git a/crates/engine/src/tests/symbollib.rs b/crates/engine/src/tests/symbollib.rs index febc220296..54591c4658 100644 --- a/crates/engine/src/tests/symbollib.rs +++ b/crates/engine/src/tests/symbollib.rs @@ -84,7 +84,7 @@ fn the_builtin_library_is_original_art_generated_in_code() { let mut s = Session::new(); let list = run(&mut s, "symbol.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); - assert_eq!(libs.len(), 4, "{libs:?}"); + assert_eq!(libs.len(), 5, "{libs:?}"); assert_eq!((libs[0]["id"].as_str(), libs[0]["category"].as_str()), (Some("basic-shapes"), Some("builtIn"))); assert_eq!( (libs[1]["id"].as_str(), libs[1]["name"].as_str(), libs[1]["category"].as_str()), @@ -98,6 +98,10 @@ fn the_builtin_library_is_original_art_generated_in_code() { (libs[3]["id"].as_str(), libs[3]["name"].as_str(), libs[3]["category"].as_str()), (Some("inventory-icons"), Some("Inventory Icons"), Some("builtIn")) ); + assert_eq!( + (libs[4]["id"].as_str(), libs[4]["name"].as_str(), libs[4]["category"].as_str()), + (Some("skill-icons"), Some("Skill Icons"), Some("builtIn")) + ); let lib = run(&mut s, "symbol.library", json!({"library": "basic shapes"})); let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Circle", "Square", "Rounded Square", "Triangle", "Diamond", "Hexagon", "Star", "Burst", "Arrow"]); @@ -159,6 +163,32 @@ fn inventory_icons_symbols_add_without_nine_slice() { )); } +#[test] +fn skill_icons_symbols_add_without_nine_slice() { + let mut s = session(); + let lib = run(&mut s, "symbol.library", json!({"library": "skill-icons"})); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Attack", "Defend", "Heal", "Buff", "Ult"]); + let mut placed = Vec::new(); + for (i, name) in names.iter().enumerate() { + let r = run( + &mut s, + "symbol.addFromLibrary", + json!({"library": "skill-icons", "name": name, "place": true, "x": 100.0 + 80.0 * i as f64, "y": 100.0}), + ); + assert_eq!(r["added"], json!([name])); + placed.push(r["placed"].as_u64().unwrap()); + } + let d = doc(&s); + for name in &names { + assert_eq!(d.symbol_nine_slice(name), None, "{name} should not carry 9-slice guides"); + } + assert!(matches!( + d.node(NodeId(placed[0])).map(|n| &n.kind), + Some(NodeKind::SymbolInstance { symbol, .. }) if symbol == "Attack" + )); +} + #[test] fn map_markers_symbols_add_without_nine_slice() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e69dc7b97c..82adcdb707 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5900,6 +5900,12 @@ axis Y Axis Y ос Crosshair Мерник Checkpoint Контролна точка Inventory Icons Икони на инвентара + Skill Icons Икони на умения + Attack Атака + Defend Защита + Heal Лечение + Buff Усилване + Ult Улт Sword Меч Shield Щит Potion Отвара diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index fc4610e0d0..70e5ee1ee8 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5944,6 +5944,12 @@ axis Y Axis Y osa Crosshair Nišan Checkpoint Kontrolna tačka Inventory Icons Ikone inventara + Skill Icons Ikone vještina + Attack Napad + Defend Odbrana + Heal Iscjeljenje + Buff Pojačanje + Ult Ult Sword Mač Shield Štit Potion Napitak diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 44562ac0f1..9aadc2dd95 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -456,6 +456,12 @@ antiAlias Sharp Nítid Crosshair Punt de mira Checkpoint Punt de control Inventory Icons Icones d’inventari + Skill Icons Icones d'habilitat + Attack Atac + Defend Defensa + Heal Curació + Buff Potenciació + Ult Ult Sword Espasa Shield Escut Potion Poció diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e42f9bd215..f23f842bc5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6128,6 +6128,12 @@ axis Y Axis osa Y Crosshair Zaměřovač Checkpoint Kontrolní bod Inventory Icons Ikony inventáře + Skill Icons Ikony dovedností + Attack Útok + Defend Obrana + Heal Léčení + Buff Posílení + Ult Ult Sword Meč Shield Štít Potion Lektvar diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index cf7aa67b3f..bc878124b4 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5915,6 +5915,12 @@ axis Y Axis Y-akse Crosshair Sigtekorn Checkpoint Kontrolpunkt Inventory Icons Inventarikoner + Skill Icons Færdighedsikoner + Attack Angreb + Defend Forsvar + Heal Helbredelse + Buff Buff + Ult Ult Sword Sværd Shield Skjold Potion Eliksir diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b339033e66..dea0ce4651 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5711,6 +5711,12 @@ axis Y Axis Y-Achse Crosshair Fadenkreuz Checkpoint Kontrollpunkt Inventory Icons Inventar-Symbole + Skill Icons Fähigkeitssymbole + Attack Angriff + Defend Verteidigung + Heal Heilung + Buff Buff + Ult Ult Sword Schwert Shield Schild Potion Trank diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 114ed5a524..42ee612ea0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6079,6 +6079,12 @@ axis Y Axis Άξονας Y Crosshair Σταυρόνημα Checkpoint Σημείο ελέγχου Inventory Icons Εικονίδια αποθέματος + Skill Icons Εικονίδια δεξιοτήτων + Attack Επίθεση + Defend Άμυνα + Heal Θεραπεία + Buff Ενίσχυση + Ult Ult Sword Ξίφος Shield Ασπίδα Potion Φίλτρο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c0e50d82e9..a92e2bfadf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6070,6 +6070,12 @@ axis Y Axis Eje Y Crosshair Punto de mira Checkpoint Punto de control Inventory Icons Iconos de inventario + Skill Icons Iconos de habilidad + Attack Ataque + Defend Defensa + Heal Curación + Buff Mejora + Ult Ult Sword Espada Shield Escudo Potion Poción diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index cdbd875764..3ba6ebfdb2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6158,6 +6158,12 @@ axis Y Axis Y-akseli Crosshair Tähtäin Checkpoint Tarkastuskohta Inventory Icons Inventaarioikonit + Skill Icons Taitoikonit + Attack Hyökkäys + Defend Puolustus + Heal Parannus + Buff Buffi + Ult Ult Sword Miekka Shield Kilpi Potion Taikajuoma diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index cb81c4902c..1056a5dccb 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6123,6 +6123,12 @@ axis Y Axis Axe Y Crosshair Réticule Checkpoint Point de contrôle Inventory Icons Icônes d’inventaire + Skill Icons Icônes de compétences + Attack Attaque + Defend Défense + Heal Soin + Buff Bonus + Ult Ult Sword Épée Shield Bouclier Potion Potion diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 296f18e805..c8b2dc57d3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -527,6 +527,12 @@ antiAlias Sharp शार्प Crosshair क्रॉसहेयर Checkpoint चेकपॉइंट Inventory Icons इन्वेंटरी आइकन + Skill Icons कौशल आइकन + Attack आक्रमण + Defend रक्षा + Heal उपचार + Buff बफ़ + Ult अल्ट Sword तलवार Shield ढाल Potion औषधि diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 34b6be0ab7..d219482bc8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6111,6 +6111,12 @@ axis Y Axis Y tengely Crosshair Célkereszt Checkpoint Ellenőrzőpont Inventory Icons Leltári ikonok + Skill Icons Képességikonok + Attack Támadás + Defend Védelem + Heal Gyógyítás + Buff Erősítés + Ult Ult Sword Kard Shield Pajzs Potion Bájital diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 16854b5bb7..1881d15f8c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5915,6 +5915,12 @@ axis Y Axis Sumbu Y Crosshair Bidik silang Checkpoint Pos pemeriksaan Inventory Icons Ikon Inventaris + Skill Icons Ikon Keahlian + Attack Serang + Defend Bertahan + Heal Pulihkan + Buff Buff + Ult Ult Sword Pedang Shield Perisai Potion Ramuan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6a8f3d7253..cd0bdf88b0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5745,6 +5745,12 @@ axis Y Axis Asse Y Crosshair Mirino Checkpoint Punto di controllo Inventory Icons Icone inventario + Skill Icons Icone abilità + Attack Attacco + Defend Difesa + Heal Cura + Buff Potenziamento + Ult Ult Sword Spada Shield Scudo Potion Pozione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index aabe534b69..9262cbf061 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5810,6 +5810,12 @@ axis Y Axis Y 軸 Crosshair クロスヘア Checkpoint チェックポイント Inventory Icons インベントリアイコン + Skill Icons スキルアイコン + Attack 攻撃 + Defend 防御 + Heal 回復 + Buff バフ + Ult 必殺 Sword 剣 Shield 盾 Potion ポーション diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index bb6a5da7d9..8fc3e844f8 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6051,6 +6051,12 @@ axis Y Axis Y ღერძი Crosshair სამიზნე Checkpoint საკონტროლო წერტილი Inventory Icons ინვენტარის ხატულები + Skill Icons უნარების ხატულები + Attack შეტევა + Defend თავდაცვა + Heal განკურნება + Buff გაძლიერება + Ult ულტი Sword მახვილი Shield ფარი Potion წამალი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b7f5824635..33efe3fc0b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5706,6 +5706,12 @@ axis Y Axis Y축 Crosshair 십자선 Checkpoint 체크포인트 Inventory Icons 인벤토리 아이콘 + Skill Icons 스킬 아이콘 + Attack 공격 + Defend 방어 + Heal 치유 + Buff 버프 + Ult 궁극기 Sword 검 Shield 방패 Potion 포션 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 32db6785bb..940c29c872 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5966,6 +5966,12 @@ axis Y Axis Y-as Crosshair Vizier Checkpoint Controlepunt Inventory Icons Inventarisiconen + Skill Icons Vaardigheidsiconen + Attack Aanval + Defend Verdediging + Heal Genezen + Buff Buff + Ult Ult Sword Zwaard Shield Schild Potion Drank diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 398adf5f50..3508f8ff7c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5982,6 +5982,12 @@ axis Y Axis Oś Y Crosshair Celownik Checkpoint Punkt kontrolny Inventory Icons Ikony ekwipunku + Skill Icons Ikony umiejętności + Attack Atak + Defend Obrona + Heal Leczenie + Buff Wzmocnienie + Ult Ult Sword Miecz Shield Tarcza Potion Mikstura diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9ede5521a8..4420ed889a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6043,6 +6043,12 @@ axis Y Axis Eixo Y Crosshair Mira Checkpoint Ponto de verificação Inventory Icons Ícones de inventário + Skill Icons Ícones de habilidade + Attack Ataque + Defend Defesa + Heal Cura + Buff Buff + Ult Ult Sword Espada Shield Escudo Potion Poção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 50eb9e969d..ed2885af4c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5739,6 +5739,12 @@ axis Y Axis Ось Y Crosshair Прицел Checkpoint Контрольная точка Inventory Icons Иконки инвентаря + Skill Icons Иконки навыков + Attack Атака + Defend Защита + Heal Лечение + Buff Усиление + Ult Ульт Sword Меч Shield Щит Potion Зелье diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 60324bb91e..86b31cb8b2 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6151,6 +6151,12 @@ axis Y Axis Y-axel Crosshair Hårkors Checkpoint Kontrollpunkt Inventory Icons Inventarieikoner + Skill Icons Färdighetsikoner + Attack Attack + Defend Försvar + Heal Läkning + Buff Buff + Ult Ult Sword Svärd Shield Sköld Potion Dryck diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 496dba8cf5..61035b2e12 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6084,6 +6084,12 @@ axis Y Axis Y ekseni Crosshair Nişangah Checkpoint Kontrol noktası Inventory Icons Envanter Simgeleri + Skill Icons Yetenek Simgeleri + Attack Saldırı + Defend Savunma + Heal İyileştirme + Buff Güçlendirme + Ult Ult Sword Kılıç Shield Kalkan Potion İksir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c1b1c3bdaf..ea09c80e3c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5725,6 +5725,12 @@ axis Y Axis Вісь Y Crosshair Приціл Checkpoint Контрольна точка Inventory Icons Піктограми інвентарю + Skill Icons Іконки навичок + Attack Атака + Defend Захист + Heal Лікування + Buff Посилення + Ult Ульт Sword Меч Shield Щит Potion Зілля diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d458c348ee..27a6b28fb2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5726,6 +5726,12 @@ axis Y Axis Trục Y Crosshair Tâm ngắm Checkpoint Điểm kiểm tra Inventory Icons Biểu tượng túi đồ + Skill Icons Biểu tượng kỹ năng + Attack Tấn công + Defend Phòng thủ + Heal Hồi máu + Buff Buff + Ult Ult Sword Kiếm Shield Khiên Potion Thuốc diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 028bd60571..ab565b6373 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5966,6 +5966,12 @@ axis Y Axis Y 轴 Crosshair 准星 Checkpoint 检查点 Inventory Icons 物品栏图标 + Skill Icons 技能图标 + Attack 攻击 + Defend 防御 + Heal 治疗 + Buff 增益 + Ult 终极技 Sword 剑 Shield 盾 Potion 药水 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7cb218f99f..fec78601a3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5763,6 +5763,12 @@ axis Y Axis Y 軸 Crosshair 準星 Checkpoint 檢查點 Inventory Icons 物品欄圖示 + Skill Icons 技能圖示 + Attack 攻擊 + Defend 防禦 + Heal 治療 + Buff 增益 + Ult 必殺 Sword 劍 Shield 盾 Potion 藥水 diff --git a/crates/ui-egui/src/panels/tests_symbol_libraries.rs b/crates/ui-egui/src/panels/tests_symbol_libraries.rs index a6c1cb4cb5..1e71583a4b 100644 --- a/crates/ui-egui/src/panels/tests_symbol_libraries.rs +++ b/crates/ui-egui/src/panels/tests_symbol_libraries.rs @@ -113,7 +113,7 @@ fn the_window_menu_and_the_symbols_panel_open_symbol_libraries() { let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Symbol Libraries"]; let opened: Vec<_> = entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.symbolLibrary")).map(|e| e.params["library"].clone()).collect(); - assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames"), json!("map-markers"), json!("inventory-icons")]); + assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames"), json!("map-markers"), json!("inventory-icons"), json!("skill-icons")]); let has = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(has("window.symbolLibrary.other"), Some(true)); assert_eq!(has("ui.saveSymbolLibrary"), Some(false), "no symbols to save yet"); diff --git a/docs/gaps.md b/docs/gaps.md index 07308c801a..ecc1b2cf14 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -90,7 +90,7 @@ stage ([ROADMAP.md](../ROADMAP.md)). | 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 0 | no | [effects-parity.md](effects-parity.md) | | 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 13–23 | no | [target-app-parity.md](target-app-parity.md) | | 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 4–10 | Wayland pressure | [hardware-parity.md](hardware-parity.md) | -| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 7–17 | no | [target-app-parity.md](target-app-parity.md) | +| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 5–15 | no | [target-app-parity.md](target-app-parity.md) | | 12 | [G3 3D and Materials](#g3-3d-and-materials) | 25–45 | no | [effects-parity.md](effects-parity.md) | | 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) | | 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 4–8 | no | [target-app-parity.md](target-app-parity.md) | @@ -618,9 +618,10 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi symbol static drops them; stored in the native format. Color eyedropper for scatter, art and pattern brushes (Hue Shift colorization; samples canvas art); symbol libraries ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol - Libraries Menu open each library in a library panel of its own (four original libraries generated in code, + Libraries Menu open each library in a library panel of its own (five original libraries generated in code, Basic Shapes, UI Frames with default 9-slice guides on Panel, Button and Bar, Map Markers with Pin, - Flag, Crosshair and Checkpoint, and Inventory Icons with Sword, Shield, Potion, Key and Gem; User Defined; Other + Flag, Crosshair and Checkpoint, Inventory Icons with Sword, Shield, Potion, Key and Gem, and Skill Icons with + Attack, Defend, Heal, Buff and Ult; User Defined; Other Library… reads the symbols of any document VectorCraft opens); a click adds a symbol to the document's Symbols with its Symbol Options and the resources it uses, as one undo step, and dragging one onto the canvas also places it; Save Symbols / Save Symbol Library writes the @@ -649,10 +650,10 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). - **Missing:** more built-in brush libraries beyond the five shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic - Shapes, UI Frames, Map Markers and Inventory Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; + Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). -- **Estimate:** 6–16 h. +- **Estimate:** 5–15 h. ## G3 3D and Materials @@ -853,6 +854,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | | 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | | 2026-11-11 | minor | G5: built-in Status Effects graphic style library (Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade, Shield Buff); Missing trimmed; estimate 7–17 → 6–16 h; MCP docs list UI Frames and Map Markers under Symbol libraries. | | 2026-11-11 | minor | G5: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem; no default 9-slice); Missing trimmed; estimate 8–18 → 7–17 h. | diff --git a/docs/mcp.md b/docs/mcp.md index c766d812a1..5d4c5cdbd8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3138,8 +3138,8 @@ with the same extension and bytes as one there, loads as that User Defined libra ## Symbol libraries -Symbol libraries are read-only sets of symbols: four generated in code (Basic Shapes, UI Frames, Map Markers, -Inventory Icons), the `.vectorcraft` files in +Symbol libraries are read-only sets of symbols: five generated in code (Basic Shapes, UI Frames, Map Markers, +Inventory Icons, Skill Icons), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the symbols of any document loaded with `symbol.loadLibrary`. `symbol.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `symbol.library {library}` returns one's symbols (`name`, `size`, `registration`, `exportType`; `library` is an diff --git a/docs/roadmap.md b/docs/roadmap.md index 241e786f95..1326c42137 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, four generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, five generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, five generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | @@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | | 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged | | 2026-11-10 | minor | M10/G5: Inventory Icons built-in symbol library (Sword, Shield, Potion, Key, Gem); estimates unchanged | | 2026-11-10 | minor | M7: the Edit Custom Dictionary dialog (#4936); the Check Spelling dialog remains; scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f52cee3a7f..9599a6373d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -223,7 +223,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers and Inventory Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | @@ -232,7 +232,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers and Inventory Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | @@ -496,7 +496,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers and Inventory Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | @@ -539,7 +539,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~42% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have four (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries five (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~44% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries five (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -807,7 +807,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). - Swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped on the window, as File › Open opens them. - - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in libraries, Basic Shapes, UI Frames (Panel, Button and Bar with default 9-slice guides), Map Markers (Pin, Flag, Crosshair and Checkpoint, no default 9-slice) and Inventory Icons (Sword, Shield, Potion, Key and Gem, no default 9-slice), are original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. + - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in libraries, Basic Shapes, UI Frames (Panel, Button and Bar with default 9-slice guides), Map Markers (Pin, Flag, Crosshair and Checkpoint, no default 9-slice), Inventory Icons (Sword, Shield, Potion, Key and Gem, no default 9-slice) and Skill Icons (Attack, Defend, Heal, Buff and Ult, no default 9-slice), are original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a ``, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). - **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`. - **Dynamic symbol appearance follows what an object draws** (VectorCraft's own rule): `symbol.instance.setAppearance` refuses a fill or stroke for an object of the art that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a nested symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect) with an error that names the key and the object, and the call changes nothing. Opacity, blend and `null`, which takes a stored change back, are taken for every object. Tests: `crates/engine/src/tests/dynamic_symbols.rs`. @@ -1975,6 +1975,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | | 2026-11-11 | minor | Bundled content: built-in Status Effects graphic style library; style libraries evidence updated; ~40% → ~42%; MCP Symbol libraries list UI Frames and Map Markers; more built-in graphic-style libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem); symbol libraries evidence updated; ~38% → ~40%; more built-in symbol libraries remain. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 44d0b19a4e..296826671f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -410,7 +410,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Pathfinder shape results keep their source names and SVG IDs | done | #1293; Minus Front keeps the back name, Minus Back the front name; Unite/Intersect/Exclude use the backmost named input; compound output carries it once; geometry and paint unchanged | | Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) | | Make Compound Path on a group (such as outlined type) keeps its backmost path's paint; compound paths joined into one keep their holes | done | #1537 | -| Symbol libraries: Window › Symbol Libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; four built-in libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons; original, generated in code) | +| Symbol libraries: Window › Symbol Libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; five built-in libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons; original, generated in code) | | Brush libraries: Window › Brush Libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; four built-in libraries (original, generated in code) | | Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (scaled instances keep their corners; in symbol editing mode the guides are drawn over the art and the Selection tool drags them); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) | | Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; Direct Selection of a path, compound path or type object inside an instance, with the Fill/Stroke controls setting its solid fill or stroke, is on canvas; on-canvas editing beyond a solid fill and stroke, and `.ai` read and write, remain | From 53bd6ecd9a648213bdcad38d729c314f4547ebec Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 09:19:21 -0800 Subject: [PATCH 37/49] JavaScript: new XML copies a one-item list's node, and insertChildAfter and insertChildBefore take fuel per child scanned new XML(list) now returns a deep copy of the list's single item, as ECMA-357 section 13.4.2 specifies for an XMLList argument; before, it returned the item itself, so changes to the new value changed the list. insertChildAfter and insertChildBefore take one unit of fuel for each child they compare with the reference, as childIndex does. The test for the missing xpath method now checks the XML and XMLList prototypes; reading .xpath on a value selects child elements and returned an empty list in every case. --- crates/js/src/xml_builtins.rs | 13 ++++++++++--- crates/js/src/xml_tests.rs | 24 +++++++++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index 35c5788304..07e2a1d12e 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -330,7 +330,7 @@ fn xml_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> J let v = arg(args, 0); interp.rooted(std::slice::from_ref(&v), |i| { let x = to_xml(i, &v)?; - if construct && matches!(i.classify(&v), Xv::Node(_)) { + if construct && matches!(i.classify(&v), Xv::Node(_) | Xv::List(_)) { return Ok(Value::Object(i.node_copy(x)?)); } Ok(Value::Object(x)) @@ -883,8 +883,15 @@ fn insert_relative(interp: &mut Interp, this: Value, args: &[Value], after: bool if after { 0 } else { len } } else { let Xv::Node(r) = interp.classify(&reference) else { return Ok(Value::Undefined) }; - interp.use_fuel()?; - let Some(i) = interp.xml_node(x)?.children.iter().position(|&c| c == r) else { return Ok(Value::Undefined) }; + let mut found = None; + for i in 0..len { + interp.use_fuel()?; + if interp.xml_node(x)?.children.get(i) == Some(&r) { + found = Some(i); + break; + } + } + let Some(i) = found else { return Ok(Value::Undefined) }; if after { i + 1 } else { i } }; interp.rooted(std::slice::from_ref(&v), |i| i.xml_insert(x, at, &c))?; diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 051c676a9d..bdcf79917b 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -290,6 +290,8 @@ fn the_constructors_keep_identity_or_copy_as_specified() { truth("XML().nodeKind() === 'text' && XML(null) == '' && XML(undefined) == ''"); truth("var x = ; XML(x) === x && new XML(x) !== x && new XML(x) == x"); truth("var l = <>; XML(l) === l[0]"); + truth("var l = <>; var y = new XML(l); y !== l[0] && y == l[0] && y.parent() === null"); + truth("var l = <>; var y = new XML(l); y.@p = '1'; y.appendChild(); l[0].@p.length() === 0 && l[0].*.length() === 0"); assert_eq!(thrown_name("XML(<>)"), "TypeError"); assert_eq!(thrown_name("XML('')"), "SyntaxError"); assert_eq!(thrown_name("XML('')"), "SyntaxError"); @@ -368,7 +370,7 @@ fn the_xml_methods_behave_as_specified() { assert_eq!(string("(1).toString()"), "1"); assert_eq!(string("().toString()"), "\n \n"); truth("var x = ; x.valueOf() === x"); - truth("().xpath === undefined || ().xpath.length() === 0"); + truth("!Object.prototype.hasOwnProperty.call(XML.prototype, 'xpath') && !Object.prototype.hasOwnProperty.call(XMLList.prototype, 'xpath')"); truth("var x = ; x.b.attributes !== undefined"); } @@ -716,6 +718,26 @@ fn catastrophic_queries_end_with_the_fuel_limit() { assert_eq!(kind_of(limits, &serialize), RunErrorKind::Limit(LimitKind::Fuel)); } +/// The fuel that `action` takes when it runs after `setup`. +fn fuel_of(setup: &str, action: &str) -> u64 { + let mut r = realm(); + r.run(setup).unwrap(); + r.run(action).unwrap(); + Limits::default().fuel - r.fuel_left() +} + +#[test] +fn insert_child_before_and_after_take_fuel_for_each_child_they_scan() { + let narrow = "var x = ; var last = x.b[0];"; + let wide = "var x = ; for (var i = 0; i < 2000; i++) x.appendChild(); var last = x.b[1999];"; + for action in + ["x.insertChildAfter(, )", "x.insertChildBefore(, )", "x.insertChildAfter(last, )", "x.insertChildBefore(last, )"] + { + let (n, w) = (fuel_of(narrow, action), fuel_of(wide, action)); + assert!(w >= n + 1999, "{action}: {n} with one child, {w} with 2000"); + } +} + #[test] fn limits_cannot_be_caught_by_the_script() { assert_eq!(kind_of(TIGHT, "try { for (;;) ().toXMLString(); } catch (e) { 1 }"), RunErrorKind::Limit(LimitKind::Fuel)); From e4c768842870d8fa47e5a48aaf716d15c8d8b246 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 17:26:05 +0000 Subject: [PATCH 38/49] EPS import: charge scanned strings, end failed filter decodes, bound data sources and JPEG/TIFF headers --- crates/eps/src/import/data/filters.rs | 10 ++++++++ crates/eps/src/import/data/ops.rs | 24 +++++++++++++++++ crates/eps/src/import/interp.rs | 22 +++++++++++++++- crates/eps/src/import/preview.rs | 7 +++++ crates/eps/src/import/tests.rs | 37 +++++++++++++++++++++++++++ crates/eps/src/import/tests_images.rs | 18 +++++++++++++ 6 files changed, 117 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/data/filters.rs b/crates/eps/src/import/data/filters.rs index e3268b240a..79dce67ea0 100644 --- a/crates/eps/src/import/data/filters.rs +++ b/crates/eps/src/import/data/filters.rs @@ -131,6 +131,10 @@ fn lzw(raw: &[u8]) -> Res<(Vec, usize)> { } } +/// Most pixels a DCTDecode JPEG may have: twice the most an image is read with, so every image that +/// was read before is still read. +const MAX_DCT_PIXELS: u64 = 1 << 27; + /// A JPEG's pixels (gray, RGB or CMYK, 8 bits) with its channel count, and how many bytes of /// `raw` it took. A CMYK JPEG gives its samples as stored, for the image's Decode array to read /// (Adobe's CMYK JPEGs store them inverted, which a Decode array of `[1 0 1 0 1 0 1 0]` undoes). @@ -143,6 +147,12 @@ fn dct(raw: &[u8]) -> Res<(Vec, usize, u8)> { { return Ok((inks.data.into_iter().map(|v| 255 - v).collect(), end, 4)); } + // The header's size is checked before the decoder allocates for it (a progressive JPEG keeps its + // coefficients besides its output). + let size = image::ImageReader::with_format(std::io::Cursor::new(jpeg), image::ImageFormat::Jpeg).into_dimensions(); + if size.is_ok_and(|(w, h)| u64::from(w) * u64::from(h) > MAX_DCT_PIXELS) { + return Err(PsError::Limit("decoded data is too large")); + } let img = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg).map_err(|_| PsError::Ps("ioerror", "DCTDecode".into()))?; if img.color().channel_count() == 1 { Ok((img.to_luma8().into_raw(), end, 1)) } else { Ok((img.to_rgb8().into_raw(), end, 3)) } } diff --git a/crates/eps/src/import/data/ops.rs b/crates/eps/src/import/data/ops.rs index 33f394482a..04e5431d30 100644 --- a/crates/eps/src/import/data/ops.rs +++ b/crates/eps/src/import/data/ops.rs @@ -7,6 +7,9 @@ use super::{ /// Most bytes one filter or image reads. pub(super) const MAX_DATA: usize = 1 << 28; + +/// Most data sources one image may name (a DeviceN space has up to 32 components). +const MAX_SOURCES: usize = 32; /// The longest chain of filters over one pending file (real programs stack two or three). const MAX_FILTER_CHAIN: usize = 64; @@ -389,6 +392,25 @@ impl Interp<'_> { /// Decode the filters a file waits to run over the data the program reads, from where it is /// now in the file they were made over: a file being run, or the program's own file. pub(in super::super) fn materialize(&mut self, f: &Rc>) -> Res { + let input = match &*f.borrow() { + Stream::Pending { from: Some(r), .. } => match &*r.borrow() { + Stream::Buf { data, pos, .. } => data.len().saturating_sub(*pos), + _ => 0, + }, + Stream::Pending { from: None, .. } => self.lex.data().len(), + _ => return Ok(()), + }; + let r = self.decode_pending(f); + // A decode that failed is over: the stream is empty from now on, and the work it did is + // charged, so a program that catches the error can't have it run again and again. + if r.is_err() && matches!(&*f.borrow(), Stream::Pending { .. }) { + *f.borrow_mut() = Stream::Buf { data: vec![], pos: 0, rgb: None }; + self.spend_bytes(input)?; + } + r + } + + fn decode_pending(&mut self, f: &Rc>) -> Res { let (chain, from) = match &*f.borrow() { Stream::Pending { chain, from } => (chain.clone(), from.clone()), _ => return Ok(()), @@ -663,6 +685,8 @@ impl Interp<'_> { let multi = get("MultipleDataSources").is_some_and(|o| matches!(o, Obj::Bool(true))); let sources = match (get("DataSource"), multi) { (None, _) => vec![], + // An image has a data source for each of its components, so many more is no image: refused before any copy. + (Some(Obj::Array { items, exec: false }), true) if items.len() > MAX_SOURCES => return ps_err("rangecheck", "DataSource"), (Some(Obj::Array { items, exec: false }), true) => items.borrow().iter().map(source).collect::>>()?, (Some(src), _) => vec![source(&src)?], }; diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 4920b1d66d..992a8bbcea 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -550,10 +550,30 @@ impl<'a> Interp<'a> { /// An object as the scanner gives it: with `immediate`, its `//name`s replaced by their /// values (down through procedures), as they are when read. - pub fn scanned(&self, o: Obj, immediate: bool) -> Res { + pub fn scanned(&mut self, o: Obj, immediate: bool) -> Res { + // Every scan builds a fresh string or procedure, so a loop that rescans one text is charged + // for each copy it keeps, not only for the text. + self.charge_scanned(&o, 0)?; if immediate { self.resolve(o, 0) } else { Ok(o) } } + /// Count the storage of the string or procedure the scanner just made against [`MAX_MEMORY`], + /// and the time to build it. + fn charge_scanned(&mut self, o: &Obj, depth: usize) -> Res { + match o { + Obj::Str(s) => { + self.alloc(s.len())?; + self.spend_bytes(s.len()) + } + Obj::Array { items, .. } if depth < MAX_DEPTH as usize => { + self.alloc(items.len().saturating_mul(std::mem::size_of::()))?; + self.spend_bytes(items.len())?; + items.borrow().iter().try_for_each(|i| self.charge_scanned(i, depth + 1)) + } + _ => Ok(()), + } + } + fn resolve(&self, o: Obj, depth: usize) -> Res { match o { Obj::Exec(n) if n.starts_with(IMMEDIATE) => { diff --git a/crates/eps/src/import/preview.rs b/crates/eps/src/import/preview.rs index 9a3f5b7511..d4b4efb131 100644 --- a/crates/eps/src/import/preview.rs +++ b/crates/eps/src/import/preview.rs @@ -28,7 +28,14 @@ pub(super) fn preview(bytes: &[u8], ps: &[u8], frame: Rect) -> Option { epsi(ps).and_then(|img| image_doc(img, frame)).map(|document| Preview { document, what: IMAGE }) } +/// Most pixels a TIFF preview may have: its RGBA copy is four bytes each besides the decoded image. +const MAX_PREVIEW_PIXELS: u64 = 1 << 26; + fn tiff_rgba(tiff: &[u8]) -> Option { + let size = image::ImageReader::with_format(std::io::Cursor::new(tiff), image::ImageFormat::Tiff).into_dimensions(); + if size.is_ok_and(|(w, h)| u64::from(w) * u64::from(h) > MAX_PREVIEW_PIXELS) { + return None; + } match image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff) { Ok(img) => Some(img.to_rgba8()), Err(_) => crate::tiff::palette_rgba(tiff), diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index f4edc0ac8f..6a1df810bc 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1588,3 +1588,40 @@ fn abutting_blocks_of_distinct_colours_stay_apart() { let gaps: String = (0..10).map(|i| format!("{} setgray {} 10 2 80 rectfill ", f64::from(i) / 40.0, 10 + i * 3)).collect(); assert_eq!(objects(&read(&gaps).document).len(), 10); } + +/// A string a program scans again and again (`token`) makes a fresh copy each time, and the copies +/// count against the memory budget. +#[test] +fn rescanned_strings_are_charged() { + let body = "/s 1048576 string def s 0 40 put s 1048575 41 put\n1100 { s token pop pop pop } repeat\n"; + let e = import(&eps(body)).unwrap_err(); + assert!(e.contains("too much memory"), "{e}"); + // A few scans stay far within it. + let r = import(&eps("/s 1048576 string def s 0 40 put s 1048575 41 put\n20 { s token pop pop pop } repeat\n")).unwrap(); + assert!(!r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); +} + +/// An image names no more data sources than a color space has components: thousands of references +/// to one string are refused before any is copied. +#[test] +fn an_image_with_thousands_of_data_sources_is_refused() { + let sources = "<80> ".repeat(2048); + let e = import(&eps(&format!( + "/DeviceCMYK setcolorspace << /ImageType 1 /Width 1 /Height 1 /BitsPerComponent 8 \ + /MultipleDataSources true /ImageMatrix [1 0 0 1 0 0] /DataSource [{sources}] >> image" + ))) + .unwrap_err(); + assert!(e.contains("rangecheck") && e.contains("DataSource"), "{e}"); +} + +/// A filter over the program's own file that fails to decode is spent: a program that catches the +/// error and reads again gets the end of the file, not the same decode over again. +#[test] +fn a_failed_filter_decode_is_not_tried_again() { + let body = "/f currentfile /FlateDecode filter def\n/n 0 def 3 { { f read { pop } if } stopped { /n n 1 add def } if } repeat\n\ + n 1 eq { 0 0 10 10 rectfill } if\n"; + let r = import(&eps(body)).unwrap(); + let mut paths = 0; + r.document.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + assert_eq!(paths, 1, "{:?}", r.warnings); +} diff --git a/crates/eps/src/import/tests_images.rs b/crates/eps/src/import/tests_images.rs index 8aad47fc77..193b81a233 100644 --- a/crates/eps/src/import/tests_images.rs +++ b/crates/eps/src/import/tests_images.rs @@ -548,3 +548,21 @@ fn the_data_of_an_image_too_large_to_read_is_skipped() { assert_eq!(super::tests::fill(&o[0]), Some(&vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))), "{program}"); } } + +/// A JPEG whose header claims far more pixels than an image may have is refused before the decoder +/// allocates for them. +#[test] +fn a_jpeg_with_a_huge_header_is_refused_before_it_is_decoded() { + let mut jpeg = vec![]; + image::codecs::jpeg::JpegEncoder::new(&mut jpeg).encode(&[128; 64], 8, 8, image::ExtendedColorType::L8).unwrap(); + // The frame header (marker, length, precision, height, width) now says 65535 × 65535. + let sof = jpeg.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap(); + jpeg[sof + 5..sof + 9].fill(0xFF); + let jpeg: String = jpeg.iter().map(|b| format!("{b:02x}")).collect(); + let e = import(&eps(&format!( + "/DeviceGray setcolorspace << /ImageType 1 /Width 1 /Height 1 /BitsPerComponent 8 /ImageMatrix [1 0 0 1 0 0] \ + /DataSource currentfile /ASCIIHexDecode filter /DCTDecode filter >> image\n{jpeg}>" + ))) + .unwrap_err(); + assert!(e.contains("too large"), "{e}"); +} From 949cbe7bea039f7e477ade8c9d570517a599b509 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 09:36:44 -0800 Subject: [PATCH 39/49] Tests: plug-in folder tests take turns on the folder last loaded The folder the Additional Plug-ins Folder preference or plugin.reload last loaded is kept for the whole process. The six engine tests that set the preference or run plugin.reload now hold one lock, so another test loading its own folder between two steps cannot change what the next step loads or lists. --- crates/engine/src/tests/plugins.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 3c647deb01..a4be6e554f 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -13,6 +13,11 @@ use crate::*; const DESATURATE: &[u8] = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate"; +/// Held by the tests that set the Additional Plug-ins Folder preference or run `plugin.reload`: +/// the folder last loaded and its report are kept for the whole process, so another test loading +/// its own folder between two steps would change what the next step loads or lists. +static PREFS_FOLDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } @@ -1278,6 +1283,7 @@ fn filters_keep_their_data_with_the_document() { #[test] fn plugin_management_commands() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); install(&mut s, DESATURATE); let list = run(&mut s, "plugin.list", json!({})); @@ -1319,6 +1325,7 @@ fn plugin_management_commands() { /// installs what is in it, as setting any other folder does. #[test] fn plugins_folder_set_again_after_clearing_installs_its_plugins() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let dir = crate::tests_links::Folder::new("plugin-folder-cleared"); let id = "test.engine.foldercleared"; let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); @@ -1998,6 +2005,7 @@ fn a_grants_file_that_cannot_be_deleted_keeps_the_stored_module() { /// the plug-in store stays, with the stored module it covers. #[test] fn a_plugin_folder_ends_the_process_grant_of_an_id_it_replaces() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); let id = "test.engine.permissions.folderstale"; let empty = json!({"objects": []}); @@ -2175,6 +2183,7 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { /// grants held by the process. #[test] fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); let id = "test.engine.permissions.folderbytes"; let plain = "test.engine.permissions.folderplain"; @@ -2403,6 +2412,7 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() { /// until it is granted on its own. #[test] fn granted_permissions_are_kept_and_folders_skip_the_rest() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let folder = crate::tests_links::Folder::new("plugin-store-grants"); let id = "test.engine.permissions.stored"; let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); @@ -2491,6 +2501,7 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { /// covers no module. #[test] fn a_grant_covers_only_the_module_it_was_given_to() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let folder = crate::tests_links::Folder::new("plugin-store-grant-hash"); let id = "test.engine.granthash"; let mut s = session(); From a07e05a207ca582334d607bc77272628ad167743 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 12:42:58 -0500 Subject: [PATCH 40/49] ci: run Harden-Runner on every job (#6629) Adds step-security/harden-runner (audit, pinned to v2.22.4 like craft-common-actions) as the first step of each job that did not have it, so every workflow in the org's public repos reports its egress to StepSecurity. Audit, not block: block-mode allow lists come from each job's observed traffic. --- .github/workflows/affinity-corpus.yml | 4 ++++ .github/workflows/packaging-lint.yml | 4 ++++ .github/workflows/release.yml | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/.github/workflows/affinity-corpus.yml b/.github/workflows/affinity-corpus.yml index 948b87be97..41ac5a00d7 100644 --- a/.github/workflows/affinity-corpus.yml +++ b/.github/workflows/affinity-corpus.yml @@ -24,6 +24,10 @@ jobs: CARGO_PROFILE_DEV_DEBUG: "0" CARGO_PROFILE_TEST_DEBUG: "0" steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index da85536c43..0a91cc0505 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -29,6 +29,10 @@ jobs: name: Windows installation policy (no app build) runs-on: windows-latest steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a57e0e6b0..eee66ab3be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,10 @@ jobs: permissions: contents: read steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Git dependencies come from storytold run: | From 8269e140ceed2463bbc9c4c17553e0a763396c12 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 09:46:57 -0800 Subject: [PATCH 41/49] Scripts: the backslash path test expects no lookups on Windows only Outside Windows, `\\host\s\x.txt`, `\\?\C:\x.txt` and `\\.\pipe\x` are relative file names in the script's folder, so `exists`, `open` and `getFiles` look them up. The test now expects no lookups for them on Windows only, and on macOS and Linux checks `//host/s/x.txt`, an absolute path outside the folder, alone. The returned values are the same on every platform. --- crates/engine/src/tests/script_io.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index 2dc1253c1b..aae4810dad 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -115,7 +115,16 @@ fn script_file_unc_rejected_before_lookup() { vc.log(out);", ); assert_eq!(log, json!([[false, false, null, false, false, null, false, false, null, false, false, null]])); - assert_eq!(script_probes(), before, "no network or device path was looked up"); + if cfg!(windows) { + assert_eq!(script_probes(), before, "no network or device path was looked up"); + } else { + // Outside Windows the backslash forms are relative file names in the script's folder, and + // `//host/s/x.txt` is an absolute path outside it. + let before = script_probes(); + let log = logged(&base, "var f = new File('//host/s/x.txt'); vc.log([f.exists, f.open('r'), new Folder('//host/s/x.txt').getFiles()]);"); + assert_eq!(log, json!([[false, false, null]])); + assert_eq!(script_probes(), before, "a path outside the script's folder was not looked up"); + } let _ = std::fs::remove_dir_all(base); } From 28042c2e928024b48234827d3d8f871201f95dac Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 09:47:33 -0800 Subject: [PATCH 42/49] JavaScript: test that new XML copies a list item's attributes and children The test for new XML with a one-item list now starts from an item that has a parent, an attribute and a child. It checks that the copy has no parent and that changing the copy's attribute and child leaves the original unchanged, so a copy that drops or shares the children fails. --- crates/js/src/xml_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index bdcf79917b..dce24d9d95 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -290,8 +290,10 @@ fn the_constructors_keep_identity_or_copy_as_specified() { truth("XML().nodeKind() === 'text' && XML(null) == '' && XML(undefined) == ''"); truth("var x = ; XML(x) === x && new XML(x) !== x && new XML(x) == x"); truth("var l = <>; XML(l) === l[0]"); - truth("var l = <>; var y = new XML(l); y !== l[0] && y == l[0] && y.parent() === null"); - truth("var l = <>; var y = new XML(l); y.@p = '1'; y.appendChild(); l[0].@p.length() === 0 && l[0].*.length() === 0"); + truth("var p =

; var l = p.a; var y = new XML(l); y !== l[0] && y == l[0] && y.parent() === null && l[0].parent() === p"); + truth( + "var p =

; var l = p.a; var y = new XML(l); y.@q = '2'; y.c.@r = '3'; l[0].@q == '1' && l[0].c.@r.length() === 0 && y.c.@r == '3'", + ); assert_eq!(thrown_name("XML(<>)"), "TypeError"); assert_eq!(thrown_name("XML('')"), "SyntaxError"); assert_eq!(thrown_name("XML('')"), "SyntaxError"); From 5ac7d97d85e8f75f962fae9743a01b776b256637 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 08:01:01 -0800 Subject: [PATCH 43/49] Guides: ruler guides are document objects on their layers Ruler guides were a list kept beside the layers. Each guide is now an object of its layer or sublayer, always directly in it and above the layer's art. These behave as on main: - Guides draw on screen only. Exports (SVG, PDF, PNG, PSD), Fit All, Select All, the art bounds, clicks and marquees, and a layer's Pathfinder and Transform effects leave them out. The Layers panel lists them as guide rows of their layer, the highest guide.list index first. - Deleting a layer deletes its guides, and merging layers keeps their guides in the merged layer. - The guide commands number guides as guide.list does, in the order they were made. New: each guide takes an id from the document's object ids. An object command given a guide's id fails with an error, and node.move cannot put a guide into a group or out of its layers. A guide made while an opacity mask, a symbol or a pattern is edited moves to the top visible, unlocked layer when editing ends. File format: native files still save the guides in the document's guides list, in every file version, so older builds read them. Each entry may now also carry the guide's id, name, locked and hidden flags; older builds ignore those fields. Opening a file puts each saved guide on its layer (on the top visible, unlocked layer when the file names none or a layer it does not have), and each saved guide counts against the object limit for opening a file. A ruler guide node that a file holds below a layer's art moves up to the layer's guides, and one anywhere else (in a group or another object, beside the layers, or in a symbol) is removed. Ruler guides read from an .ai file become guide objects on the layer they were on, numbered in the order the file lists them. --- apps/vectorcraft-cli/src/main.rs | 7 +- crates/cad/src/scene.rs | 5 +- crates/doc/src/artboard.rs | 9 +- crates/doc/src/canvas_range.rs | 53 +- crates/doc/src/depth.rs | 9 +- crates/doc/src/document.rs | 41 +- crates/doc/src/document_ext.rs | 10 +- crates/doc/src/document_guides.rs | 485 ++++++++++++++---- crates/doc/src/guides.rs | 18 +- crates/doc/src/hit.rs | 7 +- crates/doc/src/lib.rs | 11 +- crates/doc/src/live/expand.rs | 2 + crates/doc/src/mem.rs | 2 +- crates/doc/src/node.rs | 1 + crates/doc/src/node/clip.rs | 2 +- crates/doc/src/node/kind.rs | 17 + crates/doc/src/node/ops.rs | 82 ++- crates/doc/src/node/wire.rs | 7 +- crates/doc/src/pattern.rs | 2 + crates/doc/src/selection.rs | 32 +- crates/doc/src/selection/tests.rs | 53 +- crates/doc/src/symbol_nine_slice.rs | 6 +- crates/doc/src/tests.rs | 262 ++++++++++ crates/effects/src/bake.rs | 3 +- crates/effects/src/group.rs | 18 +- crates/effects/src/group/tests.rs | 42 ++ crates/effects/src/reshape.rs | 7 +- crates/effects/src/solids.rs | 2 +- crates/effects/src/solids/source.rs | 4 +- crates/engine/src/cmd.rs | 14 +- .../engine/src/cmd/addons/cleanup/checks.rs | 6 +- .../src/cmd/addons/color/print_fixes.rs | 2 +- crates/engine/src/cmd/addons/document.rs | 10 +- crates/engine/src/cmd/appearance.rs | 2 +- crates/engine/src/cmd/assets.rs | 6 +- crates/engine/src/cmd/brushsym/dynamic.rs | 2 +- crates/engine/src/cmd/clipboard.rs | 13 +- crates/engine/src/cmd/colorcmds.rs | 2 +- crates/engine/src/cmd/colormgmt.rs | 2 +- crates/engine/src/cmd/docinfo.rs | 3 +- crates/engine/src/cmd/docmenu.rs | 110 ++-- crates/engine/src/cmd/edit.rs | 13 +- crates/engine/src/cmd/effectcmd.rs | 12 +- crates/engine/src/cmd/expand.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 4 +- crates/engine/src/cmd/flatten/apply.rs | 51 +- crates/engine/src/cmd/flatten/report.rs | 4 +- crates/engine/src/cmd/layer.rs | 32 +- crates/engine/src/cmd/layerpanel.rs | 14 +- crates/engine/src/cmd/maskedit.rs | 6 +- crates/engine/src/cmd/object/align.rs | 26 +- crates/engine/src/cmd/object/arrange.rs | 18 +- crates/engine/src/cmd/object/transform.rs | 16 +- crates/engine/src/cmd/overprint.rs | 2 +- crates/engine/src/cmd/paint.rs | 4 + crates/engine/src/cmd/panelcmds.rs | 2 +- crates/engine/src/cmd/path.rs | 6 +- crates/engine/src/cmd/pathops.rs | 4 +- crates/engine/src/cmd/patterncmds.rs | 11 +- crates/engine/src/cmd/place.rs | 2 +- crates/engine/src/cmd/printadvanced.rs | 7 +- crates/engine/src/cmd/rasterfx.rs | 6 +- crates/engine/src/cmd/select.rs | 13 +- crates/engine/src/cmd/slices.rs | 3 +- crates/engine/src/cmd/style.rs | 3 +- crates/engine/src/cmd/symboledit.rs | 16 +- crates/engine/src/cmd/wand.rs | 2 +- crates/engine/src/inspect.rs | 11 +- crates/engine/src/sanity.rs | 2 +- crates/engine/src/session/documents.rs | 6 +- crates/engine/src/session/execute.rs | 7 + crates/engine/src/tests.rs | 12 + crates/engine/src/tests/constrain_tools.rs | 4 +- crates/engine/src/tests/guide_clipboard.rs | 10 +- crates/engine/src/tests/keyguide.rs | 42 +- crates/engine/src/tests/menucmds.rs | 45 +- crates/engine/src/tests/panelcmds.rs | 6 +- crates/engine/src/tests/pathops.rs | 15 +- crates/engine/src/tests/prefs.rs | 7 +- crates/engine/src/tests/save.rs | 2 +- crates/engine/src/tests/slices.rs | 6 +- crates/engine/src/tests/textedit.rs | 2 +- crates/engine/src/tests/xform.rs | 2 +- crates/engine/tests/it/beyond_canvas.rs | 2 +- crates/engine/tests/it/clear_commands.rs | 6 +- crates/engine/tests/it/main.rs | 2 + crates/engine/tests/it/ruler_guide_nodes.rs | 439 ++++++++++++++++ crates/engine/tests/it/ruler_guide_perf.rs | 130 +++++ crates/engine/tests/it/ruler_origin.rs | 2 +- crates/eps/src/ai_editing/writer.rs | 9 + crates/eps/src/ai_editing/writer/styles.rs | 4 + crates/eps/src/import/ai/read/document.rs | 17 +- crates/eps/src/import/ai/tests.rs | 30 +- crates/eps/src/import/native/build.rs | 7 +- crates/eps/src/import/native/compare.rs | 2 +- crates/eps/src/import/tests_native.rs | 21 +- crates/eps/src/legacy_ai/writer.rs | 10 +- crates/eps/src/scene.rs | 2 + crates/format/src/lib.rs | 69 +++ crates/format/tests/it/main.rs | 1 + crates/format/tests/it/native_guides.rs | 146 ++++++ crates/format/tests/it/native_v3.rs | 16 +- crates/metafile/src/scene.rs | 5 +- crates/pdf/src/export/entry.rs | 2 +- crates/pdf/src/export/nodes.rs | 2 + crates/plugins/src/objects.rs | 5 +- crates/plugins/src/objects/tests.rs | 15 + crates/render/src/encode.rs | 4 +- crates/render/src/encode/psd.rs | 2 +- crates/render/src/encode/tests_psd.rs | 22 + crates/render/src/lru.rs | 6 +- crates/render/src/node_checks.rs | 6 +- crates/render/src/pattern.rs | 3 +- crates/render/src/renderer/nodes.rs | 10 +- crates/svg/src/export/nodes.rs | 6 +- crates/svg/src/export/output.rs | 4 + crates/svg/src/glyph.rs | 10 +- crates/svg/src/import/importer.rs | 9 +- crates/svg/tests/it/ids.rs | 18 + crates/testkit/src/fixtures.rs | 8 +- crates/testkit/src/invariants.rs | 17 +- crates/tools/src/direct/tests.rs | 6 +- crates/tools/src/guides/targets.rs | 2 +- crates/tools/src/guides/tests.rs | 10 +- crates/tools/src/rulerguide.rs | 46 +- crates/tools/src/rulerguide/tests.rs | 36 +- crates/tools/src/select/tests.rs | 30 +- crates/tools/src/slice.rs | 8 +- crates/tools/src/xform/measure.rs | 2 +- crates/ui-egui/src/canvas.rs | 5 +- crates/ui-egui/src/canvas/tests.rs | 51 +- crates/ui-egui/src/chrome/tests.rs | 25 +- crates/ui-egui/src/dialogs/tests.rs | 6 +- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/align.rs | 4 +- crates/ui-egui/src/panels/align/tests.rs | 14 +- crates/ui-egui/src/panels/layers.rs | 10 +- crates/ui-egui/src/panels/layers/model.rs | 33 +- crates/ui-egui/src/panels/layers/rows.rs | 2 +- crates/ui-egui/src/panels/layers/tests.rs | 54 +- crates/ui-egui/src/panels/layers/thumbs.rs | 3 +- crates/ui-egui/src/panels/properties/tests.rs | 2 +- crates/ui-egui/src/panels/transform.rs | 2 +- crates/ui-egui/src/tests/guide_escape.rs | 29 +- crates/ui-egui/src/tests/ruler_origin.rs | 2 +- crates/ui-egui/src/tests/sysclip.rs | 3 +- crates/ui-egui/src/tests/sysclip_guides.rs | 5 +- docs/mcp.md | 10 +- 174 files changed, 2735 insertions(+), 617 deletions(-) create mode 100644 crates/engine/tests/it/ruler_guide_nodes.rs create mode 100644 crates/engine/tests/it/ruler_guide_perf.rs create mode 100644 crates/format/tests/it/native_guides.rs diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index fab468c391..57ab103590 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -486,7 +486,12 @@ fn info(args: &[String]) -> Result<(), String> { let base = h.call("engine.execute", json!({"command": "file.info", "params": {}}))?; let doc = h.session.doc().map_err(|e| e.to_string())?.doc.clone(); let mut kinds: std::collections::BTreeMap<&'static str, usize> = Default::default(); - doc.walk(|n| *kinds.entry(n.kind_label()).or_default() += 1); + // Ruler guides are not counted among the objects. + doc.walk(|n| { + if !n.is_ruler_guide() { + *kinds.entry(n.kind_label()).or_default() += 1; + } + }); let fonts = h.call("engine.execute", json!({"command": "text.fonts", "params": {}})).unwrap_or(Value::Null); let artboards: Vec = doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect(); diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 45ed992995..3faf94423f 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -583,7 +583,8 @@ impl<'a> Scene<'a> { } fn node(&mut self, n: &Node, force: bool) { - if let NodeKind::Layer { template: true, .. } = n.kind { + // Ruler guides draw on screen only: no entity and no loss warning. + if n.is_ruler_guide() || n.is_template() { return; } // Symbols that hold symbols are written in full for each instance: depth alone doesn't @@ -676,6 +677,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.opacity = opacity; } diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index ac5db48c76..fd06ea9093 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -1,4 +1,4 @@ -use super::{Arc, Deserialize, LayerColor, Node, NodeId, NodeKind, Rect, Serialize, SymbolBoxes, skip}; +use super::{Arc, Deserialize, LayerColor, Node, NodeId, Rect, Serialize, SymbolBoxes, skip}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Artboard { @@ -204,12 +204,7 @@ impl LockedArtboards<'_> { /// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it /// is new art? pub fn overlaps(&self, n: &Node) -> bool { - if self.rects.is_empty() - || !n.visible - || n.is_layer() - || matches!(n.kind, NodeKind::Path { guide: true, .. }) - || self.pattern_art.contains(&n.id) - { + if self.rects.is_empty() || !n.visible || n.is_layer() || n.is_guide() || self.pattern_art.contains(&n.id) { return false; } if let Some(held) = self.held.borrow().get(&n.id) { diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index 1142356968..e4f267a8b6 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -123,6 +123,24 @@ fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut }); } +/// Remove the ruler guides of layer `layer` and of its sublayers that `out` picks, adding how many +/// to `dropped`. Only layers holding one are copied. +fn drop_guides(layer: &mut Arc, out: &impl Fn(&Node) -> bool, dropped: &mut usize) { + fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> bool { + n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| out(c) || holds(c, out))) + } + if !holds(layer, out) { + return; + } + let Some(children) = Arc::make_mut(layer).children_mut() else { return }; + let before = children.len(); + children.retain(|c| !out(c)); + *dropped += before - children.len(); + for c in children.iter_mut().filter(|c| c.is_layer()) { + drop_guides(c, out, dropped); + } +} + impl Document { /// Remove what a read file has beyond the canvas ([`MAX_COORD`], or not finite): objects /// (see [`BeyondCanvas::objects`]; a top-level layer clipping by a path beyond it is emptied @@ -141,8 +159,9 @@ impl Document { for c in children.iter() { layers_in(c, &mut layers); } - gone.objects += children.len(); - children.clear(); + // The layer's own ruler guides stay; only its art goes. + gone.objects += children.iter().filter(|c| !c.is_ruler_guide()).count(); + children.retain(|c| c.is_ruler_guide()); *clip = false; } } else { @@ -161,9 +180,18 @@ impl Document { self.retire_artboard_id(*id); } gone.artboards = removed.len(); - let guides = self.guides.len(); - self.guides.retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); - gone.guides = guides - self.guides.len(); + // The saved list, when the guides are not nodes yet ([`Document::guides_into_nodes`]). + let guides = self.saved_guides.len(); + self.saved_guides + .retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); + gone.guides = guides - self.saved_guides.len(); + let out = |n: &Node| match n.kind { + NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || artboard.is_some_and(|a| removed.contains(&a)), + _ => false, + }; + for layer in &mut self.layers { + drop_guides(layer, &out, &mut gone.guides); + } if !(in_range(self.ruler_origin.x) && in_range(self.ruler_origin.y)) { self.ruler_origin = Point::ZERO; gone.ruler_origin = true; @@ -232,12 +260,18 @@ mod tests { let far = Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() }; d.artboards.push(far); let guide = |pos: f64, artboard| Guide { artboard, ..Guide::new(false, pos) }; - d.guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))]; + d.saved_guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))]; d.ruler_origin = Point::new(1e17, 0.0); + // The same guides as nodes, as a document has them once read. + let mut nodes = d.clone(); + assert_eq!(nodes.guides_into_nodes(), 4); let gone = d.drop_beyond_canvas(); assert_eq!(gone, BeyondCanvas { objects: 0, artboards: 1, guides: 3, ruler_origin: true }); assert_eq!(d.artboards.len(), 1); - assert_eq!(d.guides, [guide(50.0, None)], "the far artboard's own guide goes with it"); + assert_eq!(d.saved_guides, [guide(50.0, None)], "the far artboard's own guide goes with it"); + assert_eq!(nodes.drop_beyond_canvas(), gone); + let left: Vec<(bool, f64, Option)> = nodes.ruler_guides().iter().map(|g| (g.vertical, g.pos, g.artboard)).collect(); + assert_eq!(left, [(false, 50.0, None)]); assert_eq!(d.ruler_origin, Point::ZERO); assert_eq!( gone.note().unwrap(), @@ -310,7 +344,8 @@ mod tests { let probe = Document::new(1.0, 1.0); let sub = sublayer(&probe, 40, vec![rect(41, 1e17), rect(42, 0.0)], true); let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), sub]); - d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; + d.saved_guides = + vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into(); d.assets = vec![ crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)], format_3d: None }, @@ -320,7 +355,7 @@ mod tests { d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); let gone = d.drop_beyond_canvas(); assert_eq!((gone.objects, gone.guides), (2, 1), "the far rectangle, the clipping sublayer and its layer guide"); - assert_eq!(d.guides.len(), 1, "the guide of a layer that stays, stays"); + assert_eq!(d.saved_guides.len(), 1, "the guide of a layer that stays, stays"); assert_eq!(d.saved_selections[0].objects, [NodeId(10)]); assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::>(), [(2, vec![NodeId(10)])]); assert_eq!(d.variables.bindings.keys().copied().collect::>(), [NodeId(10)]); diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 5abba9c63b..63b7e0ce7c 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -132,7 +132,7 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { out.push(a); continue; } - let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + let clip = n.clips(); let (visible, locked) = (n.visible, n.locked); let mut held = n.children_mut().map(std::mem::take).unwrap_or_default(); if clip && !held.is_empty() { @@ -148,6 +148,13 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { // What it held takes its place, and is looked at in its turn. left.extend(held.into_iter().rev()); } + // The ruler guides of a sublayer merged into its parent layer go above the parent's art, with + // the parent's own. + if out.iter().any(|c| c.is_ruler_guide()) { + let (guides, art): (Vec<_>, Vec<_>) = out.into_iter().partition(|c| c.is_ruler_guide()); + out = art; + out.extend(guides); + } *nodes = out; true } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index fecdc9bacb..708d0aee03 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -10,7 +10,7 @@ use super::{ impl Document { /// Is everything but the layers the same in `other`, as the next version of the document after - /// an edit of its art? The id counter and the id index are left out, and art outside the layers + /// an edit of its art? The id counter and the caches are left out, and art outside the layers /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it /// doesn't change: quick for every frame of an animation. pub fn same_outside_layers(&self, other: &Self) -> bool { @@ -38,7 +38,7 @@ impl Document { text_threads, symbols, graph_designs, - guides, + saved_guides, views, saved_selections, variables, @@ -77,6 +77,7 @@ impl Document { opened_artboard: _, units_from_file: _, index: _, + guide_cache: _, } = self; *version == other.version && *title == other.title @@ -98,7 +99,7 @@ impl Document { && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) && graph_designs.len() == other.graph_designs.len() && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) - && *guides == other.guides + && *saved_guides == other.saved_guides && *views == other.views && *saved_selections == other.saved_selections && *variables == other.variables @@ -221,7 +222,7 @@ impl Document { text_threads: vec![], symbols: vec![], graph_designs: vec![], - guides: vec![], + saved_guides: vec![], views: vec![], saved_selections: Default::default(), variables: Default::default(), @@ -260,6 +261,7 @@ impl Document { opened_artboard: None, units_from_file: false, index: Default::default(), + guide_cache: Default::default(), }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); @@ -501,11 +503,13 @@ impl Document { } } - /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). + /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). In a layer, art goes + /// below the layer's ruler guides and a ruler guide among them ([`Node::insert_index`]). pub fn insert(&mut self, parent: Option, index: usize, node: Node) -> Result { let id = node.id; + self.check_ruler_guide_place(&node, parent)?; let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); + let i = Node::insert_index(ch, index, &node); ch.insert(i, Arc::new(node)); Ok(id) } @@ -522,20 +526,33 @@ impl Document { Ok(ch.remove(idx)) } - /// Move a node to `parent` at `index` (index measured after removal). + /// Move a node to `parent` at `index` (index measured after removal), clamped as + /// [`Document::insert`] clamps it. pub fn move_node(&mut self, id: NodeId, parent: Option, index: usize) -> Result<(), DocError> { if let Some(p) = parent && self.ancestry(p).is_some_and(|a| a.contains(&id)) { return Err(DocError::Invalid("cannot move an object into itself".into())); } + if let Some(n) = self.node(id) { + self.check_ruler_guide_place(n, parent)?; + } let n = self.remove(id)?; let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); + let i = Node::insert_index(ch, index, &n); ch.insert(i, n); Ok(()) } + /// An error when `n` is a ruler guide and `parent` is not a layer or sublayer: a ruler guide is + /// always a direct child of one ([`NodeKind::RulerGuide`]). + fn check_ruler_guide_place(&self, n: &Node, parent: Option) -> Result<(), DocError> { + if n.is_ruler_guide() && !parent.and_then(|p| self.node(p)).is_some_and(Node::is_layer) { + return Err(DocError::Invalid("a ruler guide goes only directly in a layer or sublayer".into())); + } + Ok(()) + } + /// Position of `id` within its parent: (parent, index, sibling count). pub fn position(&self, id: NodeId) -> Option<(Option, usize, usize)> { let parent = self.parent_of(id); @@ -739,7 +756,7 @@ impl Document { for c in n.children().into_iter().flatten() { if c.is_layer() { objects(c, out); - } else { + } else if !c.is_ruler_guide() { out.insert(c.id); } } @@ -757,7 +774,8 @@ impl Document { } /// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else - /// the group or other object around it right inside a layer. None for a layer or sublayer. + /// the group or other object around it right inside a layer. None for a layer, a sublayer or a + /// ruler guide. pub fn layer_object(&self, id: NodeId) -> Option<&Node> { self.layer_object_at(&self.index_path(id)?) } @@ -768,6 +786,9 @@ impl Document { let mut nodes = &self.layers; for &i in path { let n = nodes.get(i)?; + if n.is_ruler_guide() { + return None; + } if !n.is_layer() { return Some(n.as_ref()); } diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index ebb2fc44eb..0f1ec463c4 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -56,9 +56,12 @@ impl Document { if self.saved_selections.is_empty() { return; } + // A saved selection names objects; ruler guides are not among them. let mut ids = std::collections::HashSet::new(); self.walk(|n| { - ids.insert(n.id); + if !n.is_ruler_guide() { + ids.insert(n.id); + } }); let mut kept: Vec = vec![]; for s in std::mem::take(&mut self.saved_selections) { @@ -101,12 +104,13 @@ impl Document { /// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working /// copy of art the mask or the symbol already holds (the engine syncs it after every edit). The /// layer is removed wherever it is, also as a sublayer of another layer; an id that names - /// anything other than a layer is left alone. + /// anything other than a layer is left alone. Ruler guides on the layer stay in the document + /// ([`Self::remove_edit_layer`]). pub fn drop_edit_modes(&mut self) { let layers = [self.mask_edit.take().map(|me| me.layer), self.symbol_edit.take().map(|se| se.layer)]; for id in layers.into_iter().flatten() { if self.node(id).is_some_and(Node::is_layer) { - let _ = self.remove(id); + let _ = self.remove_edit_layer(id); } } } diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 510de3a4de..ca6b5ed882 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -1,6 +1,8 @@ use super::document_ext::free_id; use super::guides::Guide; +use super::limits::MAX_NEXT_ID; use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds}; +use std::collections::{BTreeMap, HashSet}; impl Document { /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol @@ -151,7 +153,7 @@ impl Document { rest.iter().any(|m| has(m, s, inside, true)) } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect, exact)), - NodeKind::Path { guide: true, .. } => false, + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => false, _ => art_box_meets(c, s, rect, exact), } } @@ -171,6 +173,195 @@ impl Document { art } + /// Put the ruler guide nodes a file holds where ruler guides go: directly in a layer or + /// sublayer, above its art. A ruler guide below art in a layer moves up to the layer's other + /// guides, keeping their order. A ruler guide anywhere else (at the top level, in a group or + /// another object, in opacity-mask art, in a symbol, a graph design or a pattern) is removed. + /// Returns how many were removed. + pub fn tidy_ruler_guides(&mut self) -> usize { + let mut gone = self.layers.len(); + self.layers.retain(|l| !l.is_ruler_guide()); + gone -= self.layers.len(); + for l in &mut self.layers { + if layer_untidy(l) { + gone += tidy_layer(Arc::make_mut(l)); + } + } + let others = self + .symbols + .iter_mut() + .map(|s| &mut s.art) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in others { + if root.is_ruler_guide() { + *root = Arc::new(Node::group(root.id, vec![])); + gone += 1; + } else if any_guide(root) { + gone += drop_guides_below(Arc::make_mut(root)); + } + } + gone + } + + /// Make the saved ruler guides (read from a file: [`Document::guides_to_saved`]'s list) into + /// [`NodeKind::RulerGuide`] nodes, each at the top of its layer, in list order. A guide on a + /// layer or sublayer the document has goes on it; one on no layer, or on a layer that is gone, + /// goes on the top visible, unlocked layer ([`Self::default_layer`]), or on a new "Layer 1" + /// when there is none. A guide keeps its saved id when no other node, slice or guide has it, + /// and its name, lock and visibility. The saved list is emptied, so running this again does + /// nothing. Returns how many guides became nodes. + pub fn guides_into_nodes(&mut self) -> usize { + let saved = std::mem::take(&mut self.saved_guides); + if saved.is_empty() { + return 0; + } + let mut used = HashSet::new(); + self.walk(|n| { + used.insert(n.id); + }); + used.extend(self.slices.iter().map(|s| s.id)); + used.extend(self.assets.iter().map(|a| NodeId(a.id))); + let kept: Vec> = saved.iter().map(|g| g.id.filter(|id| id.0 > 0 && id.0 < MAX_NEXT_ID && used.insert(*id))).collect(); + if let Some(max) = kept.iter().flatten().map(|id| id.0).max() { + self.raise_next_id(max.saturating_add(1)); + } + let mut home = None; + // The guides of each layer, in list order. + let mut by_layer: BTreeMap>> = BTreeMap::new(); + let mut order = vec![]; + for (g, id) in saved.iter().zip(kept) { + let layer = match g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) { + Some(l) => l, + None => match home { + Some(h) => h, + None => { + let h = self.default_layer().unwrap_or_else(|| self.add_layer(None)); + home = Some(h); + h + } + }, + }; + let id = id.unwrap_or_else(|| self.alloc_id()); + let mut n = Node::new(id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }); + n.name = g.name.clone(); + n.locked = g.locked; + n.visible = !g.hidden; + if !by_layer.contains_key(&layer) { + order.push(layer); + } + by_layer.entry(layer).or_default().push(Arc::new(n)); + } + for layer in order { + let guides = by_layer.remove(&layer).unwrap_or_default(); + if let Ok(ch) = self.children_mut(Some(layer)) { + ch.extend(guides); + } + } + saved.len() + } + + /// Take the ruler guide nodes out of the layers and put them in the saved list, by ascending + /// id, each naming its layer, after any entries not yet made into nodes: the form a file keeps + /// them in, readable by versions from before ruler guides were nodes. For saving a copy of + /// the document; [`Self::guides_into_nodes`] reverses it. + pub fn guides_to_saved(&mut self) { + let guides = self.ruler_guides(); + if guides.is_empty() { + return; + } + for l in &mut self.layers { + if l.holds_ruler_guides() { + Arc::make_mut(l).strip_ruler_guides(); + } + } + self.saved_guides.extend(guides); + } + + /// Every ruler guide, by ascending id, with the layer or sublayer it is on, its id, name and + /// its own lock and visibility. A guide's place in this list is its index in the guide + /// commands. + pub fn ruler_guides(&self) -> Vec { + if let Some(listed) = self.guide_cache.get(&self.layers) { + return listed.as_ref().clone(); + } + fn visit(layer: &Node, out: &mut Vec) { + for c in layer.children().into_iter().flatten() { + if let Some(g) = guide_of(c, layer.id) { + out.push(g); + } else if c.is_layer() { + visit(c, out); + } + } + } + let mut out = vec![]; + for l in &self.layers { + visit(l, &mut out); + } + out.sort_by_key(|g| g.id); + self.guide_cache.set(&self.layers, &out); + out + } + + /// Ruler guide `id`, as [`Self::ruler_guides`] lists it. None when `id` is not a ruler guide + /// on a layer. + pub fn ruler_guide(&self, id: NodeId) -> Option { + let layer = self.parent_of(id)?; + guide_of(self.node(id)?, layer) + } + + /// Change ruler guide `id` with `f` (its line, its artboard; the layer, id, name, lock and + /// visibility fields are not read back). Returns false when `id` is not a ruler guide. + pub fn edit_ruler_guide(&mut self, id: NodeId, f: impl FnOnce(&mut Guide)) -> bool { + let Some(mut g) = self.ruler_guide(id) else { return false }; + f(&mut g); + let Some(n) = self.node_mut(id) else { return false }; + n.kind = NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }; + true + } + + /// Add ruler guide `g` as a node with a new id at the top of its layer: `g.layer` when the + /// document has that layer or sublayer, else the top visible, unlocked layer + /// ([`Self::default_layer`]), or a new "Layer 1" when there is none. Its name, lock and + /// visibility come from `g`. Returns the new id. + pub fn add_ruler_guide(&mut self, g: &Guide) -> NodeId { + let layer = match g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) { + Some(l) => l, + None => self.default_layer().unwrap_or_else(|| self.add_layer(None)), + }; + let id = self.alloc_id(); + let mut n = Node::new(id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }); + n.name = g.name.clone(); + n.locked = g.locked; + n.visible = !g.hidden; + if let Ok(ch) = self.children_mut(Some(layer)) { + ch.push(Arc::new(n)); + } + id + } + + /// Remove node `id`, the temporary layer of opacity-mask, symbol or pattern editing, keeping the + /// ruler guides on it and on its sublayers: they go to the top of the top visible, unlocked + /// layer left ([`Self::default_layer`]), or of a new "Layer 1" when no layer is left, where a + /// saved guide whose layer is gone opens. Returns the removed node, without the guides. + pub fn remove_edit_layer(&mut self, id: NodeId) -> Result, crate::DocError> { + let mut layer = self.remove(id)?; + if !layer.holds_ruler_guides() { + return Ok(layer); + } + let mut guides = vec![]; + layer.walk(&mut |c| { + if c.is_ruler_guide() { + guides.push(Arc::new(c.clone())); + } + }); + Arc::make_mut(&mut layer).strip_ruler_guides(); + let home = self.default_layer().unwrap_or_else(|| self.add_layer(None)); + self.children_mut(Some(home))?.extend(guides); + Ok(layer) + } + /// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its /// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose /// artboard is gone. @@ -179,79 +370,39 @@ impl Document { Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) } + /// The ruler guide node `g` names, if this document has it. + fn guide_node(&self, g: &Guide) -> Option { + g.id.filter(|id| self.node(*id).is_some_and(Node::is_ruler_guide)) + } + /// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has. fn guide_layer(&self, g: &Guide) -> Option { g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) } - /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is - /// visible? A guide whose layer is gone shows as one on no layer. + /// Is ruler guide `g` shown: its node, and the layers it is on, visible? A guide that is not a + /// node of this document shows unless hidden itself or on a hidden layer. pub fn guide_shown(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_visible(l)) - } - - /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? - pub fn guide_editable(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_editable(l)) - } - - /// Put the ruler guides on no layer this document has (from files saved before guides had - /// layers, or of layers merged into `layer`) on layer `layer`. Returns how many. - pub fn rehome_guides(&mut self, layer: NodeId) -> usize { - let lost: Vec = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect(); - let mut n = 0; - for (g, lost) in self.guides.iter_mut().zip(lost) { - if lost { - g.layer = Some(layer); - n += 1; - } + match self.guide_node(g) { + Some(id) => self.is_visible(id), + None => !g.hidden && self.guide_layer(g).is_none_or(|l| self.is_visible(l)), } - n } - /// Put the ruler guides on no layer (from files saved before guides had layers), or on one that - /// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many. - pub fn adopt_guides(&mut self) -> usize { - self.default_layer().map_or(0, |home| self.rehome_guides(home)) + /// Can ruler guide `g` be picked, moved and deleted: shown, and neither it nor its layers + /// locked? + pub fn guide_editable(&self, g: &Guide) -> bool { + match self.guide_node(g) { + Some(id) => self.is_editable(id), + None => !g.hidden && !g.locked && self.guide_layer(g).is_none_or(|l| self.is_editable(l)), + } } - /// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in - /// step. Guides on no layer stay. Returns how many went. - pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize { - let gone: Vec = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect(); - self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false)) - } - - /// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made - /// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies - /// the objects on it. Returns how many. - pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize { - let layers = |n: &Node| { - let mut out = vec![]; - n.walk(&mut |c| { - if c.is_layer() { - out.push(c.id); - } - }); - out - }; - let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect(); - let copies: Vec = self - .guides - .iter() - .filter_map(|g| { - let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1; - Some(Guide { layer: Some(to), ..g.clone() }) - }) - .collect(); - let n = copies.len(); - self.guides.extend(copies); - n - } - - /// The ruler guides on layer `layer` (not its sublayers'), by index. - pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { - self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) + /// The ruler guides directly on layer `layer` (not its sublayers'), by ascending id. + pub fn guides_on(&self, layer: NodeId) -> Vec { + let mut out: Vec = self.children(Some(layer)).into_iter().flatten().filter_map(|c| guide_of(c, layer)).collect(); + out.sort_by_key(|g| g.id); + out } /// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)? @@ -260,45 +411,86 @@ impl Document { self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol) } - /// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their - /// place among the rest (the selected art stays selected). Returns how many went. - pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize { - let kept: Vec = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect(); - // Where each kept guide ends up. - let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0); - for k in &kept { - to.push(k.then_some(n)); - n += usize::from(*k); + /// Keep the ruler guides `keep` accepts, deleting the others; `sel` forgets the deleted ones + /// (the selected art stays selected). Returns how many went. + pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(&Guide) -> bool) -> usize { + let gone: HashSet = self.ruler_guides().into_iter().filter(|g| !keep(g)).filter_map(|g| g.id).collect(); + if gone.is_empty() { + return 0; + } + fn drop(layer: &mut Arc, gone: &HashSet) { + fn holds(n: &Node, gone: &HashSet) -> bool { + n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| gone.contains(&c.id) || holds(c, gone))) + } + if !holds(layer, gone) { + return; + } + let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; + ch.retain(|c| !(c.is_ruler_guide() && gone.contains(&c.id))); + for c in ch.iter_mut().filter(|c| c.is_layer()) { + drop(c, gone); + } + } + for l in &mut self.layers { + drop(l, &gone); + } + sel.guides.retain(|id| !gone.contains(id)); + if sel.key_guide.is_some_and(|k| gone.contains(&k)) { + sel.key_guide = None; + } + gone.len() + } + + /// Change the line of every ruler guide on artboard `id` with `f` (whether it is vertical, its + /// position). + fn edit_artboard_guides(&mut self, id: u32, f: &mut impl FnMut(bool, &mut f64)) { + fn visit(layer: &mut Arc, id: u32, f: &mut impl FnMut(bool, &mut f64)) { + fn holds(n: &Node, id: u32) -> bool { + n.is_layer() + && n.children().is_some_and(|ch| { + ch.iter().any(|c| matches!(c.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id) || holds(c, id)) + }) + } + if !holds(layer, id) { + return; + } + let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; + for c in ch.iter_mut() { + if matches!(c.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id) { + if let NodeKind::RulerGuide { vertical, pos, .. } = &mut Arc::make_mut(c).kind { + f(*vertical, pos); + } + } else if c.is_layer() { + visit(c, id, f); + } + } + } + for l in &mut self.layers { + visit(l, id, f); } - sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect(); - sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten()); - let before = self.guides.len(); - let mut flags = kept.into_iter(); - self.guides.retain(|_| flags.next().unwrap_or(true)); - before - self.guides.len() } /// Move the guides of artboard `id` by `d` (along with their artboard). pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - *g = g.moved(d); - } + self.edit_artboard_guides(id, &mut |vertical, pos| *pos += if vertical { d.x } else { d.y }); } /// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork /// with Artboard). pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y }; - } + self.edit_artboard_guides(id, &mut |vertical, pos| { + *pos = if vertical { (xf * Point::new(*pos, 0.0)).x } else { (xf * Point::new(0.0, *pos)).y }; + }); } /// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their - /// artboard). + /// artboard), each on its guide's layer with a new id. pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) { let copies: Vec = - self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); - self.guides.extend(copies); + self.ruler_guides().into_iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); + for g in &copies { + self.add_ruler_guide(g); + } } /// An id for a new artboard: one no artboard has, above the ids given out before and the ids of @@ -367,3 +559,122 @@ impl Document { n } } + +/// Does `n`, or anything below it (opacity-mask art included), hold a ruler guide? +fn any_guide(n: &Node) -> bool { + let mut any = false; + crate::swatches::walk_with_masks(n, &mut |m| any |= m.is_ruler_guide()); + any +} + +/// Remove every ruler guide below `n`, in its members and its opacity-mask art (`n` itself +/// stays). Mask art that is a ruler guide becomes an empty group. Returns how many went. +fn drop_guides_below(n: &mut Node) -> usize { + let mut gone = drop_mask_guides(n); + if let Some(ch) = n.children_mut() { + let before = ch.len(); + ch.retain(|c| !c.is_ruler_guide()); + gone += before - ch.len(); + for c in ch.iter_mut().filter(|c| any_guide(c)) { + gone += drop_guides_below(Arc::make_mut(c)); + } + } + gone +} + +/// Remove every ruler guide in the opacity-mask art of `n`. Mask art that is a ruler guide +/// becomes an empty group. Returns how many went. +fn drop_mask_guides(n: &mut Node) -> usize { + let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) else { return 0 }; + if mask.art.is_ruler_guide() { + mask.art = Arc::new(Node::group(mask.art.id, vec![])); + 1 + } else if any_guide(&mask.art) { + drop_guides_below(Arc::make_mut(&mut mask.art)) + } else { + 0 + } +} + +/// Does layer `l` hold a ruler guide out of place: below art, in an object or in mask art, or so +/// in one of its sublayers? +fn layer_untidy(l: &Node) -> bool { + let Some(ch) = l.children() else { return false }; + let start = Node::guide_run_start(ch); + l.extras().mask.as_deref().is_some_and(|m| any_guide(&m.art)) + || ch.iter().take(start).any(|c| c.is_ruler_guide() || if c.is_layer() { layer_untidy(c) } else { any_guide(c) }) +} + +/// [`Document::tidy_ruler_guides`] for layer `l`. Returns how many guides were removed. +fn tidy_layer(l: &mut Node) -> usize { + let mut gone = drop_mask_guides(l); + let Some(ch) = l.children_mut() else { return gone }; + for c in ch.iter_mut() { + if c.is_layer() { + if layer_untidy(c) { + gone += tidy_layer(Arc::make_mut(c)); + } + } else if !c.is_ruler_guide() && any_guide(c) { + gone += drop_guides_below(Arc::make_mut(c)); + } + } + if ch.iter().take(Node::guide_run_start(ch)).any(|c| c.is_ruler_guide()) { + let (guides, art): (Vec<_>, Vec<_>) = std::mem::take(ch).into_iter().partition(|c| c.is_ruler_guide()); + *ch = art; + ch.extend(guides); + } + gone +} + +/// Node `n`, on layer `layer`, as a [`Guide`] value, if it is a ruler guide. +fn guide_of(n: &Node, layer: NodeId) -> Option { + let NodeKind::RulerGuide { vertical, pos, artboard } = n.kind else { return None }; + Some(Guide { vertical, pos, artboard, layer: Some(layer), id: Some(n.id), name: n.name.clone(), locked: n.locked, hidden: !n.visible }) +} + +/// The list [`Document::ruler_guides`] last made, with weak pointers to the top-level layers it was +/// made from. While a weak pointer to a layer's allocation is held, nothing else can be at that +/// address, and the layer can't be changed in place (`Arc::get_mut` refuses it and `Arc::make_mut` +/// moves it), so any edit inside a layer, and any layer added, removed or replaced, gives a list of +/// layers the cache does not match. Clones of a document share the cached list. +#[derive(Default)] +pub(crate) struct GuideCache(std::sync::RwLock>); + +/// The top-level layers a guide list was made from, and the list. +type Listed = (Vec>, Arc>); + +impl GuideCache { + /// The cached list, when it was made from `layers`. + fn get(&self, layers: &[Arc]) -> Option>> { + let g = self.0.read().ok()?; + let (from, listed) = g.as_ref()?; + let same = from.len() == layers.len() && from.iter().zip(layers).all(|(w, l)| std::ptr::eq(w.as_ptr(), Arc::as_ptr(l))); + same.then(|| listed.clone()) + } + + /// Keep `listed`, made from `layers`. + fn set(&self, layers: &[Arc], listed: &[Guide]) { + if let Ok(mut g) = self.0.write() { + *g = Some((layers.iter().map(Arc::downgrade).collect(), Arc::new(listed.to_vec()))); + } + } +} + +impl Clone for GuideCache { + fn clone(&self) -> Self { + GuideCache(std::sync::RwLock::new(self.0.read().ok().and_then(|g| g.clone()))) + } +} + +/// A cache, not document content: two documents are equal whatever their caches hold. +impl PartialEq for GuideCache { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for GuideCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("GuideCache") + } +} diff --git a/crates/doc/src/guides.rs b/crates/doc/src/guides.rs index 08f828391b..a3fb9969eb 100644 --- a/crates/doc/src/guides.rs +++ b/crates/doc/src/guides.rs @@ -1,5 +1,8 @@ use super::{Deserialize, NodeId, Serialize, Vec2}; +/// A ruler guide as a value: an entry of the saved `guides` list, or a ruler guide node of a +/// document as [`crate::Document::ruler_guides`] lists it. In a document, ruler guides are +/// [`crate::NodeKind::RulerGuide`] nodes on their layers. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). @@ -14,12 +17,25 @@ pub struct Guide { /// from before guides had layers, and from files that don't say), always there. #[serde(default, skip_serializing_if = "Option::is_none")] pub layer: Option, + /// The id of its node. None in an entry written before ruler guides were nodes: it gets a new + /// id when the document is read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// The name it shows in the Layers panel, if it was given one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Locked on its own (its layer's lock is separate). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub locked: bool, + /// Hidden on its own (its layer's visibility is separate). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hidden: bool, } impl Guide { /// A canvas guide. pub fn new(vertical: bool, pos: f64) -> Self { - Self { vertical, pos, artboard: None, layer: None } + Self { vertical, pos, artboard: None, layer: None, id: None, name: None, locked: false, hidden: false } } /// Moved by `d` (a vertical guide across, a horizontal one down). diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 3412a7ff40..94fbb5ff52 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -588,7 +588,8 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols if !c.visible || editable_only && (c.locked || c.is_template()) { return false; } - if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { + // A ruler guide is picked by the guide commands, never by an object hit test. + if c.is_ruler_guide() || opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { return false; } // (An envelope's content sits where it was, not where the envelope draws it.) @@ -1256,7 +1257,7 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh // marquee touches none of it, as clicks don't (#5309). Some(s) if !doc.is_editable(s.id) => vec![], Some(s) => match s.children() { - Some(c) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), + Some(c) => c.iter().enumerate().filter(|(i, c)| !c.is_ruler_guide() && !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), // A single object isolated: only it. None => doc .parent_of(s.id) @@ -1285,7 +1286,7 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh if c.visible && !c.locked && !c.is_template() { out.extend(layer_objects(c, r)); } - } else { + } else if !c.is_ruler_guide() { out.push((c, r)); } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1f555b74e3..41a684abde 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -207,8 +207,12 @@ pub struct Document { /// Object › Graph › Design…: art graphs can draw as markers. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub graph_designs: Vec, - #[serde(default)] - pub guides: Vec, + /// The saved form of the ruler guides: read from a file, then made into + /// [`NodeKind::RulerGuide`] nodes on their layers ([`Document::guides_into_nodes`]), and + /// written from those nodes when saving ([`Document::guides_to_saved`]). Empty in between. + /// Each entry read counts as an object against the read's node budget. + #[serde(default, rename = "guides", deserialize_with = "crate::node::counted_list")] + saved_guides: Vec, /// View → New View… (at most [`SavedView::MAX`]); saved with the document. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub views: Vec, @@ -351,6 +355,9 @@ pub struct Document { /// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved. #[serde(skip)] index: id_index::IdIndex, + /// The ruler guides as [`Document::ruler_guides`] lists them: a cache, never saved. + #[serde(skip)] + guide_cache: document_guides::GuideCache, } fn ppi72() -> f64 { diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 66d20f27b6..1cc8ba0863 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -140,6 +140,8 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { } // Its art is plain, so it warps at this depth. NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w), + // A ruler guide has no shape to warp. + NodeKind::RulerGuide { .. } => {} } out } diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index e431fc9d15..e2d27650b5 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -88,7 +88,7 @@ impl Document { } let mut n = 0; n += vec_bytes(&self.artboards) + self.artboards.iter().map(|a| a.name.capacity()).sum::(); - n += vec_bytes(&self.guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets); + n += vec_bytes(&self.saved_guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets); n += vec_bytes(&self.text_threads) + self.text_threads.iter().map(vec_bytes).sum::(); n += self.images.iter().map(|(k, b)| 4 * size_of::() + k.capacity() + b.mime.capacity() + size_of_val(b)).sum::(); n += map_bytes(&self.export_settings) + map_bytes(&self.extra) + self.print_setup.as_ref().map_or(0, json_bytes); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 774e824390..eeec6ab162 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -33,6 +33,7 @@ pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; pub use ops::{flattens, keeps_gradient_fit}; use wire::NodeWire; +pub(crate) use wire::counted_list; pub use wire::{MAX_READ_NODES, with_node_budget}; fn yes() -> bool { diff --git a/crates/doc/src/node/clip.rs b/crates/doc/src/node/clip.rs index 208bdda7ff..cb66ba1148 100644 --- a/crates/doc/src/node/clip.rs +++ b/crates/doc/src/node/clip.rs @@ -19,7 +19,7 @@ impl Node { fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) { match &self.kind { - NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {} + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } | NodeKind::SymbolInstance { .. } => {} NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)), NodeKind::Compound { children, rule } => { let mut bp = BezPath::new(); diff --git a/crates/doc/src/node/kind.rs b/crates/doc/src/node/kind.rs index 22470cbf3e..7f9e21a0ff 100644 --- a/crates/doc/src/node/kind.rs +++ b/crates/doc/src/node/kind.rs @@ -122,6 +122,17 @@ pub enum NodeKind { Intertwine { children: Vec>, }, + /// A ruler guide: a straight line across the canvas, or across one artboard, that draws on + /// screen only. It is always a direct child of a layer or sublayer, above the layer's other + /// children, and has no bounds. + RulerGuide { + /// true: a vertical guide at x = `pos`; false: a horizontal one at y = `pos`. + vertical: bool, + pos: f64, + /// The [`crate::Artboard::id`] of the artboard it runs across; None: across the canvas. + #[serde(default, skip_serializing_if = "Option::is_none")] + artboard: Option, + }, } /// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every @@ -215,6 +226,12 @@ mod kind_de { Intertwine { children: Vec>, }, + RulerGuide { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + }, } impl<'de> Deserialize<'de> for NodeKind { diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index eb7e0d39b9..415b2946e3 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -80,6 +80,16 @@ impl Node { matches!(self.kind, NodeKind::Layer { .. }) } + /// A ruler guide ([`NodeKind::RulerGuide`]). + pub fn is_ruler_guide(&self) -> bool { + matches!(self.kind, NodeKind::RulerGuide { .. }) + } + + /// A guide of either kind: a ruler guide, or a path made into a guide. + pub fn is_guide(&self) -> bool { + matches!(self.kind, NodeKind::RulerGuide { .. } | NodeKind::Path { guide: true, .. }) + } + /// A template layer (Layer Options → Template). pub fn is_template(&self) -> bool { matches!(self.kind, NodeKind::Layer { template: true, .. }) @@ -91,7 +101,7 @@ impl Node { pub fn layer_art(&self, editable: bool) -> Vec { fn visit(n: &Node, editable: bool, out: &mut Vec) { for c in n.children().into_iter().flatten() { - if editable && (!c.visible || c.locked || c.is_template()) { + if c.is_ruler_guide() || (editable && (!c.visible || c.locked || c.is_template())) { continue; } if c.is_layer() { @@ -142,6 +152,7 @@ impl Node { NodeKind::Repeat(r) => r.kind.label(), NodeKind::PlacedDocument(_) => "Placed Document", NodeKind::Intertwine { .. } => "Intertwine", + NodeKind::RulerGuide { .. } => "Guide", } } @@ -310,6 +321,8 @@ impl Node { NodeKind::PlacedDocument(p) => Some(p.bounds()), // The members drawn over the others are drawn where they are. NodeKind::Intertwine { children } => union(children), + // A ruler guide runs across the canvas (or an artboard): it adds nothing to a box. + NodeKind::RulerGuide { .. } => None, } } @@ -522,6 +535,9 @@ impl Node { } NodeKind::Mesh(m) => m.transform(a), NodeKind::Repeat(r) => r.transform(a, *sc), + // A ruler guide stays where it is when the art around it (its layer) is transformed: + // the guide commands move it. + NodeKind::RulerGuide { .. } => {} } // The frames of the objects attached to a path move with them, and the distances along the // path follow the path as it is now (a live shape's path is regenerated). @@ -557,10 +573,10 @@ impl Node { } } - /// Count of nodes in this subtree. + /// Count of nodes in this subtree, ruler guides left out. pub fn count(&self) -> usize { let mut n = 0; - self.walk(&mut |_| n += 1); + self.walk(&mut |c| n += usize::from(!c.is_ruler_guide())); n } @@ -569,7 +585,7 @@ impl Node { pub fn count_with_masks(&self) -> usize { let mut n = 0; self.walk(&mut |c| { - n += 1; + n += usize::from(!c.is_ruler_guide()); if let Some(m) = &c.extras().mask { n += m.art.count_with_masks(); } @@ -688,7 +704,7 @@ impl Node { /// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves. pub fn knockout_elements(children: &[Arc]) -> Vec<&Arc> { fn push<'a>(children: &'a [Arc], out: &mut Vec<&'a Arc>) { - for c in children.iter().filter(|c| c.visible) { + for c in children.iter().filter(|c| c.visible && !c.is_ruler_guide()) { match c.children() { Some(ch) if c.passes_knockout_through() => push(ch, out), _ => out.push(c), @@ -701,8 +717,62 @@ impl Node { } /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. + /// A layer whose first child is a ruler guide holds no art, so a guide never clips it. pub fn clips(&self) -> bool { - matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }) + match &self.kind { + NodeKind::Group { clip, .. } => *clip, + NodeKind::Layer { clip, children, .. } => *clip && children.first().is_none_or(|c| !c.is_ruler_guide()), + _ => false, + } + } + + /// Where the ruler guides at the end of `children` (a layer's) begin: the index past the last + /// child that is not one. Art goes below this index and ruler guides at or above it. + pub fn guide_run_start(children: &[Arc]) -> usize { + children.iter().rposition(|c| !c.is_ruler_guide()).map_or(0, |i| i + 1) + } + + /// Remove the ruler guides of this layer and of its sublayers. Other nodes hold none. + pub fn strip_ruler_guides(&mut self) { + if !self.holds_ruler_guides() { + return; + } + if let Some(ch) = self.children_mut() { + ch.retain(|c| !c.is_ruler_guide()); + for c in ch.iter_mut().filter(|c| c.holds_ruler_guides()) { + Arc::make_mut(c).strip_ruler_guides(); + } + } + } + + /// Call `f` on the position (along the axis it crosses) of each ruler guide of this layer and + /// of its sublayers, with whether the guide is vertical. Only layers that hold one are copied. + pub fn map_ruler_guide_positions(&mut self, f: &mut impl FnMut(bool, &mut f64)) { + if !self.holds_ruler_guides() { + return; + } + if let Some(ch) = self.children_mut() { + for c in ch.iter_mut().filter(|c| c.is_ruler_guide() || c.holds_ruler_guides()) { + let c = Arc::make_mut(c); + match &mut c.kind { + NodeKind::RulerGuide { vertical, pos, .. } => f(*vertical, pos), + _ => c.map_ruler_guide_positions(f), + } + } + } + } + + /// Whether this layer or one of its sublayers holds a ruler guide. + pub fn holds_ruler_guides(&self) -> bool { + self.is_layer() && self.children().is_some_and(|ch| ch.iter().any(|c| c.is_ruler_guide() || c.holds_ruler_guides())) + } + + /// The index that an insert into `children` at `index` uses for `node`: art stays below the + /// layer's ruler guides and a ruler guide goes among them. + pub fn insert_index(children: &[Arc], index: usize, node: &Node) -> usize { + let start = Self::guide_run_start(children); + let i = index.min(children.len()); + if node.is_ruler_guide() { i.max(start) } else { i.min(start) } } /// Make this group or layer clip its children by its first child, or stop it. diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index b54d3f4b84..2722e7bee0 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -145,9 +145,10 @@ fn count_one() -> Result<(), E> { } /// A list read from a file (a member's Intertwine areas of tier 2 and up, an instance's symbol -/// overrides), each item counting against [`with_node_budget`] as an object does: a compressed -/// file of repeated empty items stays small while they take memory once read. -fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { +/// overrides, a document's saved ruler guides), each item counting against [`with_node_budget`] +/// as an object does: a compressed file of repeated empty items stays small while they take +/// memory once read. +pub(crate) fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { struct Items(std::marker::PhantomData); impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items { type Value = Vec; diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 2a98df62ce..d1333bef0b 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -584,6 +584,8 @@ impl<'a> NestedCount<'a> { (NodeKind::Repeat(r), _) => r.in_all(self), (NodeKind::SymbolInstance { symbol, .. }, _) => Cost { instances: self.symbol(symbol), copies: 1 }, (NodeKind::Path { path, .. }, _) => Cost { instances: 0, copies: path.anchor_count().saturating_add(1) }, + // A ruler guide draws nothing, so a repeat makes no copies of it. + (NodeKind::RulerGuide { .. }, _) => Cost::default(), (_, Some(c)) => self.nodes(c).plus(Cost { instances: 0, copies: 1 }), _ => Cost { instances: 0, copies: 1 }, }; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 643f81c365..c514aab996 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -39,16 +39,16 @@ pub struct Selection { /// clears them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub slices: Vec, - /// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new + /// Ruler guides selected with a selection tool, by node id ([`Document::ruler_guides`]). A new /// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so /// guides and objects can be selected together, in either order. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub guides: Vec, - /// A selected ruler guide that is the key object for Align instead of [`Self::key`] (an index - /// into [`Document::guides`], one of [`Self::guides`]): the objects line up on it. Never set - /// with `key`; [`Self::prune`] clears it when no object is selected to align to it. + pub guides: Vec, + /// A selected ruler guide that is the key object for Align instead of [`Self::key`] (one of + /// [`Self::guides`]): the objects line up on it. Never set with `key`; [`Self::prune`] clears + /// it when no object is selected to align to it. #[serde(default, skip_serializing_if = "Option::is_none")] - pub key_guide: Option, + pub key_guide: Option, /// The new art on a locked artboard that this selection keeps editable /// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and /// the engine then sets the document's from it. Editing state, never saved. @@ -139,10 +139,10 @@ impl Selection { } } } - /// Select ruler guides `indexes` (see [`Selection::guides`]) and nothing else. - pub fn set_guides(&mut self, indexes: impl IntoIterator) { + /// Select ruler guides `ids` (see [`Selection::guides`]) and nothing else. + pub fn set_guides(&mut self, ids: impl IntoIterator) { self.clear(); - for i in indexes { + for i in ids { if !self.guides.contains(&i) { self.guides.push(i); } @@ -150,7 +150,7 @@ impl Selection { } /// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a /// Shift-click on a guide). - pub fn toggle_guide(&mut self, i: usize) { + pub fn toggle_guide(&mut self, i: NodeId) { self.symbol_part = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); @@ -201,7 +201,9 @@ impl Selection { pub fn prune(&mut self, doc: &Document) { // Every id this looks up, found in one walk of the tree. let ids = self.objects.iter().chain(self.anchors.keys()).chain(self.segments.keys()).copied().chain(self.key).chain(self.target); - let found = doc.index_paths(ids); + let mut found = doc.index_paths(ids); + // A ruler guide is selected in `guides` only, never as an object, an anchor or the key. + found.retain(|_, p| !doc.node_at(p).is_some_and(|n| n.is_ruler_guide())); self.objects.retain(|id| found.contains_key(id)); self.anchors.retain(|id, _| found.contains_key(id)); self.segments.retain(|id, refs| { @@ -209,8 +211,12 @@ impl Selection { refs.retain(|&(si, seg)| path.subpaths.get(si).is_some_and(|sp| seg < sp.segment_count())); !refs.is_empty() && self.objects.contains(id) && self.anchors.contains_key(id) }); - // Guides on a hidden or locked layer can't stay selected. - self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g))); + // Guides that are gone, or hidden or locked (themselves or by their layer), can't stay + // selected. + if !self.guides.is_empty() { + let editable = doc.editable(); + self.guides.retain(|id| doc.node(*id).is_some_and(|n| n.is_ruler_guide()) && editable(*id)); + } // The key object stays while another selected object lies outside it, or while ruler // guides are selected for it to line up (#2143). let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| { diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index 7882fef731..d0d81faee6 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -20,25 +20,30 @@ fn basic_ops() { fn guides_are_selected_on_their_own() { let mut s = Selection::default(); s.add(NodeId(1)); - s.set_guides([2, 0, 2]); - assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0])); + let (g0, g2) = (NodeId(10), NodeId(12)); + s.set_guides([g2, g0, g2]); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![g2, g0])); assert!(s.has_objects_or_guides() && s.is_empty()); // Shift-adding an object keeps them; a new selection doesn't. s.add(NodeId(1)); - assert_eq!(s.guides, vec![2, 0]); + assert_eq!(s.guides, vec![g2, g0]); s.set([NodeId(1)]); assert!(s.guides.is_empty()); // Toggled, a guide joins (or leaves) the selected objects. - s.toggle_guide(0); - assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0])); - s.toggle_guide(0); + s.toggle_guide(g0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![g0])); + s.toggle_guide(g0); assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![])); - // Pruning drops guides the document no longer has. + // Pruning drops guides the document no longer has, ids of other objects, and guides that are + // locked or hidden themselves. let mut d = Document::new(100.0, 100.0); - d.guides.push(crate::Guide::new(true, 10.0)); - s.set_guides([0, 1]); + let layer = d.layers[0].id; + let kept = d.add_ruler_guide(&crate::Guide::new(true, 10.0)); + let locked = d.add_ruler_guide(&crate::Guide { locked: true, ..crate::Guide::new(true, 20.0) }); + let hidden = d.add_ruler_guide(&crate::Guide { hidden: true, ..crate::Guide::new(true, 30.0) }); + s.set_guides([kept, NodeId(9999), layer, locked, hidden]); s.prune(&d); - assert_eq!(s.guides, vec![0]); + assert_eq!(s.guides, vec![kept]); } /// A picked object of a dynamic symbol's instance stays picked while it and the groups and layers @@ -131,16 +136,26 @@ fn guides_show_and_lock_with_their_layer() { assert!(d.guide_shown(&crate::Guide::new(false, 5.0))); let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) }; assert!(d.guide_shown(&gone) && d.guide_editable(&gone)); - d.guides.push(g); - assert_eq!(d.guides_on(layer).count(), 1); - // Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer. + // As a node, a guide shows and locks with its layer and on its own. + let id = d.add_ruler_guide(&g); + let node = d.ruler_guide(id).unwrap(); + assert_eq!(d.guides_on(layer), std::slice::from_ref(&node)); + assert!(!d.guide_shown(&node) && !d.guide_editable(&node), "its layer is hidden"); d.node_mut(layer).unwrap().visible = true; d.node_mut(layer).unwrap().locked = false; - d.guides.push(crate::Guide::new(false, 5.0)); - d.guides.push(gone); - assert_eq!(d.adopt_guides(), 2); - assert!(d.guides.iter().all(|g| g.layer == Some(layer))); - assert_eq!(d.adopt_guides(), 0, "once"); + assert!(d.guide_shown(&node) && d.guide_editable(&node)); + d.node_mut(id).unwrap().locked = true; + let node = d.ruler_guide(id).unwrap(); + assert!(d.guide_shown(&node) && !d.guide_editable(&node), "locked itself"); + d.node_mut(id).unwrap().visible = false; + assert!(!d.guide_shown(&node), "hidden itself"); + // Saved guides on no layer, or a gone one, go on the top visible, unlocked layer. + d.saved_guides.push(crate::Guide::new(false, 5.0)); + d.saved_guides.push(gone); + assert_eq!(d.guides_into_nodes(), 2); + assert!(d.ruler_guides().iter().all(|g| g.layer == Some(layer))); + assert_eq!(d.ruler_guides().len(), 3); + assert_eq!(d.guides_into_nodes(), 0, "once"); // A guide saved before guides had layers reads as one on no layer. let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap(); assert_eq!(old.layer, None); @@ -267,7 +282,7 @@ fn random_selection(r: &mut u64, ids: &[NodeId]) -> Selection { s.key = s.objects.get(below(r, s.objects.len() as u64) as usize).copied(); s.target = ids.get(below(r, ids.len() as u64) as usize).copied().filter(|_| below(r, 2) == 0); s.slices = ids.iter().copied().take(below(r, 3) as usize).collect(); - s.guides = vec![1, 0]; + s.guides = vec![NodeId(1), NodeId(0)]; s } diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 995445f578..6949aa86fb 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -492,7 +492,8 @@ impl NineSliceMap { /// [`Self::most_placed`] for a container placed by [`Self::container`]. fn most_container(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { let own = self.most_mask(n, boxes, depth).saturating_add(1); - n.children().into_iter().flatten().fold(own, |sum, c| sum.saturating_add(self.most_at(c, boxes, depth + 1))) + // As [`Self::container`] places it: a layer's ruler guides left out. + n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).fold(own, |sum, c| sum.saturating_add(self.most_at(c, boxes, depth + 1))) } /// [`Self::most_placed`] for the opacity mask of `n` as [`Self::place_mask`] places it: a @@ -560,7 +561,8 @@ impl NineSliceMap { let kids = out.children_mut().map(std::mem::take).unwrap_or_default(); out.transform(center, false); if let Some(children) = out.children_mut() { - *children = kids.iter().map(|c| Arc::new(self.place_at(c, boxes, depth + 1))).collect(); + // A ruler guide of a layer is not art: it is not placed. + *children = kids.iter().filter(|c| !c.is_ruler_guide()).map(|c| Arc::new(self.place_at(c, boxes, depth + 1))).collect(); } self.place_mask(n, &mut out, boxes, depth); out diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 21fe8d7ce9..dcbcd87028 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -91,6 +91,268 @@ fn an_inch_ruler_steps_in_binary_fractions() { assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0)); } +/// Ruler guide nodes on a layer and a sublayer are not art: Select All, a layer's target circle, +/// the object count, the art's bounds, clicks, the marquee and a knockout layer's elements are +/// the same as without them, and transforming the layer leaves them where they are. +#[test] +fn ruler_guide_nodes_are_not_art() { + use crate::hit::{HitOptions, hit_test, marquee, objects_at}; + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap(); + let s1 = d.alloc_id(); + d.insert(Some(sub), usize::MAX, Node::path(s1, shapes::rectangle(Rect::new(40.0, 0.0, 50.0, 10.0)), Appearance::default_art())).unwrap(); + let opt = HitOptions::default(); + let all = Rect::new(-100.0, -100.0, 700.0, 900.0); + let before = ( + d.selectable_art(), + d.layers[0].layer_art(false), + d.node_count(), + d.art_bounds(), + marquee(&d, all, None, false), + hit_test(&d, Point::new(5.0, 5.0), opt).map(|h| h.top_object(None)), + ); + let guide = |id, vertical, pos| Node::new(id, NodeKind::RulerGuide { vertical, pos, artboard: None }); + let (g1, g2, g3) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1, true, 5.0)).unwrap(); + d.insert(Some(layer), usize::MAX, guide(g2, false, 5.0)).unwrap(); + d.insert(Some(sub), usize::MAX, guide(g3, true, 45.0)).unwrap(); + let after = ( + d.selectable_art(), + d.layers[0].layer_art(false), + d.node_count(), + d.art_bounds(), + marquee(&d, all, None, false), + hit_test(&d, Point::new(5.0, 5.0), opt).map(|h| h.top_object(None)), + ); + assert_eq!(after, before); + assert_eq!(after.0, [a, b, s1]); + for p in [Point::new(5.0, 300.0), Point::new(300.0, 5.0), Point::new(45.0, 300.0)] { + assert!(hit_test(&d, p, opt).is_none(), "nothing to click on the guide at {p:?}"); + assert!(objects_at(&d, p, opt, None).is_empty()); + } + let mut sel = Selection::default(); + sel.set_target(&d, layer); + assert_eq!(sel.objects, [a, b, s1]); + let children = d.layers[0].children().unwrap().clone(); + assert_eq!(Node::knockout_elements(&children).iter().map(|c| c.id).collect::>(), [a, b, s1]); + assert!(d.node(g1).is_some_and(Node::is_ruler_guide) && d.node(g1).is_some_and(Node::is_guide)); + assert_eq!(d.node(g1).map(Node::geometric_bounds), Some(None)); + let mut moved = (*d.layers[0]).clone(); + moved.transform(Affine::translate((10.0, 10.0)), true); + let g = moved.children().unwrap().iter().find(|c| c.id == g1).unwrap(); + assert!(matches!(g.kind, NodeKind::RulerGuide { pos, .. } if pos == 5.0)); +} + +/// Art inserted or moved onto a layer goes below the layer's ruler guides, a ruler guide goes +/// among them, and a clipped layer whose art is gone does not clip by a guide. +#[test] +fn layer_art_stays_below_its_ruler_guides() { + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let guide = |id, pos| Node::new(id, NodeKind::RulerGuide { vertical: true, pos, artboard: None }); + let (g1, g2) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1, 5.0)).unwrap(); + // Asked for the bottom, a guide still goes above the art. + d.insert(Some(layer), 0, guide(g2, 7.0)).unwrap(); + let ids = |d: &Document| d.layers[0].children().unwrap().iter().map(|c| c.id).collect::>(); + assert_eq!(ids(&d), [a, b, g2, g1]); + let c = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(c, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art())).unwrap(); + assert_eq!(ids(&d), [a, b, c, g2, g1]); + d.move_node(a, Some(layer), usize::MAX).unwrap(); + assert_eq!(ids(&d), [b, c, a, g2, g1]); + d.move_node(g1, Some(layer), 0).unwrap(); + assert_eq!(ids(&d), [b, c, a, g1, g2]); + assert!(d.layer_object(g1).is_none()); + assert_eq!(d.layer_object(a).map(|n| n.id), Some(a)); + + // A clipped layer clips by its first art object; with only guides left it clips nothing. + if let Some(NodeKind::Layer { clip, .. }) = d.node_mut(layer).map(|n| &mut n.kind) { + *clip = true; + } + assert!(d.node(layer).is_some_and(Node::clips)); + for id in [a, b, c] { + d.remove(id).unwrap(); + } + assert!(!d.node(layer).is_some_and(Node::clips)); + + // Reading a file that clips a layer by a path beyond the canvas empties the layer of its art + // only: its guides stay. + let far = d.alloc_id(); + d.insert(Some(layer), 0, Node::path(far, shapes::rectangle(Rect::new(0.0, 0.0, 1e9, 10.0)), Appearance::default_art())).unwrap(); + assert!(d.node(layer).is_some_and(Node::clips)); + let gone = d.drop_beyond_canvas(); + assert_eq!(gone.objects, 1); + assert_eq!(ids(&d), [g1, g2]); +} + +/// `ruler_guides` keeps its list while the layers are unchanged, and lists again after any change +/// inside a layer, made in place or on a shared copy, and after layers are added, removed or +/// reordered. +#[test] +fn ruler_guides_follow_every_edit() { + let (mut d, _, _) = doc_with_rects(); + let layer = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let g1 = d.add_ruler_guide(&Guide { layer: Some(layer), ..Guide::new(true, 5.0) }); + let g2 = d.add_ruler_guide(&Guide { layer: Some(sub), ..Guide::new(false, 6.0) }); + let line = |d: &Document| d.ruler_guides().iter().map(|g| (g.id, g.pos, g.hidden, g.layer)).collect::>(); + assert_eq!(line(&d), [(Some(g1), 5.0, false, Some(layer)), (Some(g2), 6.0, false, Some(sub))]); + // Changed in place: the document is the only holder of its layers. + assert!(d.edit_ruler_guide(g2, |g| g.pos = 9.0)); + assert_eq!(line(&d)[1], (Some(g2), 9.0, false, Some(sub))); + d.node_mut(g1).unwrap().visible = false; + assert_eq!(line(&d)[0], (Some(g1), 5.0, true, Some(layer))); + // A copy shares the list until one of them changes. + let copy = d.clone(); + assert_eq!(line(©), line(&d)); + d.remove(g1).unwrap(); + assert_eq!(line(&d), [(Some(g2), 9.0, false, Some(sub))]); + assert_eq!(line(©).len(), 2); + // A new layer with a guide, then the layers reordered and one removed. + let top = d.add_layer(None); + let g3 = d.add_ruler_guide(&Guide { layer: Some(top), ..Guide::new(true, 1.0) }); + assert_eq!(line(&d).len(), 2); + d.layers.reverse(); + assert_eq!(line(&d), [(Some(g2), 9.0, false, Some(sub)), (Some(g3), 1.0, false, Some(top))]); + d.layers.retain(|l| l.id != layer); + assert_eq!(line(&d), [(Some(g3), 1.0, false, Some(top))]); +} + +#[test] +fn strip_ruler_guides_leaves_a_layers_art() { + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let guide = |id| Node::new(id, NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None }); + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let (g1, g2) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1)).unwrap(); + d.insert(Some(sub), usize::MAX, guide(g2)).unwrap(); + let mut n = (*d.layers[0]).clone(); + assert!(n.holds_ruler_guides()); + n.strip_ruler_guides(); + assert!(!n.holds_ruler_guides()); + let mut left = vec![]; + n.walk(&mut |c| left.push(c.id)); + assert_eq!(left, [layer, a, b, sub]); + // Other nodes hold none and are left as they are. + let mut p = d.node(a).unwrap().clone(); + p.strip_ruler_guides(); + assert_eq!(&p, d.node(a).unwrap()); +} + +/// The saved `guides` list becomes ruler guide nodes and is written back from them unchanged: +/// guides on a layer, a sublayer, no layer and a deleted layer, with an artboard guide, keep their +/// order, ids, names and their own lock and visibility; a saved id that another node has is +/// replaced; and the saved form of an older entry still reads. +#[test] +fn saved_guides_become_nodes_and_are_saved_back_the_same() { + let (mut d, a, _) = doc_with_rects(); + let layer = d.layers[0].id; + let top = d.add_layer(Some("Top")); + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let json = serde_json::json!([ + {"vertical": true, "pos": 10.0, "layer": layer}, + {"vertical": false, "pos": 20.0, "layer": sub, "artboard": 1}, + {"vertical": true, "pos": 30.0}, + {"vertical": false, "pos": 40.0, "layer": 9999}, + {"vertical": true, "pos": 50.0, "layer": layer, "id": 500, "name": "Edge", "locked": true, "hidden": true}, + {"vertical": true, "pos": 60.0, "layer": layer, "id": a}, + ]); + d.saved_guides = serde_json::from_value(json).unwrap(); + let before = d.peek_next_id(); + assert_eq!(d.guides_into_nodes(), 6); + assert!(d.saved_guides.is_empty()); + let guides = d.ruler_guides(); + let lines: Vec<(f64, Option)> = guides.iter().map(|g| (g.pos, g.layer)).collect(); + // A guide on no layer, or on a deleted one, goes on the top visible, unlocked layer. + assert_eq!(lines[..4], [(50.0, Some(layer)), (10.0, Some(layer)), (20.0, Some(sub)), (30.0, Some(top))], "by id: the kept id first"); + assert_eq!(lines[4..], [(40.0, Some(top)), (60.0, Some(layer))]); + assert_eq!(guides[0].id, Some(NodeId(500))); + assert!(guides[1..].iter().all(|g| g.id.unwrap().0 > 500), "new ids start above a kept one"); + assert_ne!(guides[5].id, Some(a), "an id another object has is not reused"); + assert!(d.peek_next_id() > 500 && before <= 500); + let edge = d.node(NodeId(500)).unwrap(); + assert!(edge.locked && !edge.visible && edge.name.as_deref() == Some("Edge")); + assert_eq!(guides[2].artboard, Some(1)); + // Each layer's guides sit at its top, above its art and its sublayer. + let kids: Vec = d.node(layer).unwrap().children().unwrap().iter().map(|c| c.is_ruler_guide()).collect(); + assert_eq!(kids, [false, false, false, true, true, true]); + assert_eq!(d.guides_into_nodes(), 0, "a second run does nothing"); + + // Saved back: the guides leave the layers and the list names each one's layer and id. + let mut saved = d.clone(); + saved.guides_to_saved(); + assert!(saved.ruler_guides().is_empty()); + assert_eq!(saved.node_count(), d.node_count()); + assert_eq!(saved.saved_guides, guides); + let json = serde_json::to_value(&saved).unwrap(); + assert_eq!(json["guides"].as_array().map(Vec::len), Some(6)); + assert_eq!( + json["guides"][0], + serde_json::json!({"vertical": true, "pos": 50.0, "layer": layer, "id": 500, "name": "Edge", "locked": true, "hidden": true}) + ); + // Read again, the same guides come back with the same ids. + let mut back: Document = serde_json::from_value(json).unwrap(); + back.fix_next_id(); + back.guides_into_nodes(); + assert_eq!(back.ruler_guides(), guides); + // An older reader of the list ignores the new fields. + #[derive(serde::Deserialize, PartialEq, Debug)] + struct Old { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + #[serde(default)] + layer: Option, + } + let old: Vec = serde_json::from_value(serde_json::to_value(&saved.saved_guides).unwrap()).unwrap(); + assert_eq!(old[0], Old { vertical: true, pos: 50.0, artboard: None, layer: Some(layer) }); + + // A document with no layer gets a "Layer 1" for its guides. + let mut empty = Document::new(10.0, 10.0); + empty.layers.clear(); + empty.saved_guides = vec![Guide::new(true, 1.0)]; + assert_eq!(empty.guides_into_nodes(), 1); + assert_eq!(empty.layers.len(), 1); + assert_eq!(empty.layers[0].name.as_deref(), Some("Layer 1")); + assert_eq!(empty.ruler_guides().len(), 1); +} + +/// Editing, deleting and the artboard moves of ruler guide nodes, and the selection keeping step. +#[test] +fn ruler_guide_nodes_are_edited_by_id() { + let (mut d, a, _) = doc_with_rects(); + let layer = d.layers[0].id; + let g1 = d.add_ruler_guide(&Guide { layer: Some(layer), ..Guide::new(true, 10.0) }); + let g2 = d.add_ruler_guide(&Guide { artboard: Some(1), ..Guide::new(false, 20.0) }); + assert!(d.edit_ruler_guide(g1, |g| g.pos = 15.0)); + assert!(!d.edit_ruler_guide(a, |g| g.pos = 15.0), "not a guide"); + assert_eq!(d.ruler_guide(g1).map(|g| g.pos), Some(15.0)); + d.move_artboard_guides(1, Vec2::new(5.0, 7.0)); + assert_eq!(d.ruler_guide(g2).map(|g| g.pos), Some(27.0)); + d.map_artboard_guides(1, Affine::scale(2.0)); + assert_eq!(d.ruler_guide(g2).map(|g| g.pos), Some(54.0)); + assert_eq!(d.ruler_guide(g1).map(|g| g.pos), Some(15.0), "a canvas guide stays"); + d.copy_artboard_guides(1, 2, Vec2::new(0.0, 100.0)); + let copies: Vec<(f64, Option)> = d.ruler_guides().iter().filter(|g| g.artboard == Some(2)).map(|g| (g.pos, g.artboard)).collect(); + assert_eq!(copies, [(154.0, Some(2))]); + let mut sel = Selection::default(); + sel.set_guides([g1, g2]); + sel.key_guide = Some(g2); + assert_eq!(d.retain_guides(&mut sel, |g| g.artboard.is_none()), 2); + assert_eq!(sel.guides, [g1]); + assert_eq!(sel.key_guide, None); + assert_eq!(d.ruler_guides().iter().map(|g| g.id).collect::>(), [Some(g1)]); + assert_eq!(d.retain_guides(&mut sel, |_| true), 0); +} + fn doc_with_rects() -> (Document, NodeId, NodeId) { let mut d = Document::new(612.0, 792.0); let layer = d.layers[0].id; diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index be3e99522f..5d27ac60af 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -107,7 +107,8 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind { /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { // A cancelled export leaves the rest of the document as it is: the caller drops the result. - if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { + // A ruler guide draws nothing and stays as it is, last in its layer. + if n.is_ruler_guide() || !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 73d7266c31..99e9d9e6fe 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -455,6 +455,17 @@ pub fn pathfinder_children(doc: Option<&Document>, group: &Node, hook: Option] = group.children()?; + // A layer's ruler guides take no part in the operation: they stay above its result. + let art = Node::guide_run_start(source); + if art < source.len() { + let mut without = group.clone(); + if let Some(ch) = without.children_mut() { + ch.truncate(art); + } + let mut out = pathfinder_children(doc, &without, hook)?; + out.extend(source.iter().skip(art).cloned()); + return Some(out); + } let mut stack: Vec> = if group.is_compound_shape() { // The group's own appearance paints the area ([`evaluate_container`]), so the art has none. match compound_shape_area(doc, group, hook) { @@ -727,9 +738,11 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { return None; } let mut m = n.clone(); + // A layer's ruler guides take no part: they go back above the evaluated art at the end. + let guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default(); if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); - let result = pathfinder_children(doc, n, Some(&hook)).unwrap_or_default(); + let result = pathfinder_children(doc, &m, Some(&hook)).unwrap_or_default(); if let Some(ch) = m.children_mut() { *ch = result; } @@ -762,6 +775,9 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } m.appearance.items.clear(); m.appearance.contents_index = None; + if let Some(ch) = m.children_mut() { + ch.extend(guides); + } Some(m) } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 94456c7d57..bf702b2732 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -820,3 +820,45 @@ fn nested_compound_shapes_take_part_as_their_area() { assert!((area(&out[0]) - 50.0).abs() < 0.01); assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape"); } + +/// The ruler guides of a layer with a Pathfinder effect, a Transform effect that copies its art, or +/// fills and strokes of its own take no part: they stay last in the layer, once and unchanged. +#[test] +fn a_layers_ruler_guides_stay_last_through_its_effects() { + use vectorcraft_doc::LayerColor; + let guide = |id, pos| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: true, pos, artboard: None })); + let layer = |effect: &str, params: serde_json::Value| { + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)), guide(20, 3.0), guide(21, 8.0)]; + } + l.appearance.effects.push(Effect { id: effect.into(), params, visible: true }); + l + }; + let tail = |n: &Node| { + let ch = n.children().unwrap(); + assert_eq!(ch.iter().filter(|c| c.is_ruler_guide()).count(), 2, "each guide once"); + ch.iter().rev().take(2).map(|c| c.kind.clone()).collect::>() + }; + let guides = vec![guide(21, 8.0).kind.clone(), guide(20, 3.0).kind.clone()]; + + let add = layer("pathfinder.add", serde_json::json!({})); + let out = pathfinder_children(None, &add, None).unwrap(); + assert_eq!(out.len(), 3, "the united area, then the two guides"); + assert!((area(&out[0]) - 150.0).abs() < 0.01); + assert!(out[1].is_ruler_guide() && out[2].is_ruler_guide()); + assert_eq!(tail(&evaluate_container(None, &add).unwrap()), guides); + + let copies = layer("distort.transform", serde_json::json!({"moveH": 30, "copies": 2})); + let art = evaluate_container(None, &copies).unwrap(); + assert_eq!(tail(&art), guides); + assert_eq!(art.children().unwrap().len(), 5, "three groups of the art, then the two guides"); + let mut art = copies.clone(); + crate::reshape::reshape_leaves(&mut art, &copies.appearance.effects.iter().collect::>(), &copies.appearance.effects, None); + assert_eq!(tail(&art), guides); + assert_eq!(art.children().unwrap().len(), 5, "reshaped as members too"); + + let mut painted = layer("distort.transform", serde_json::json!({"moveH": 30})); + painted.appearance.items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items; + assert_eq!(tail(&evaluate_container(None, &painted).unwrap()), guides); +} diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 0bdc134625..c629cc10ca 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -974,7 +974,7 @@ fn effects_cost(leaf: &Node, path: &PathData) -> Budget { /// Remove the guides anywhere under `n`. fn without_guides(n: &mut Node) { let Some(children) = n.children_mut() else { return }; - children.retain(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })); + children.retain(|c| !c.is_guide()); children.iter_mut().for_each(|c| without_guides(Arc::make_mut(c))); } @@ -984,7 +984,9 @@ fn without_guides(n: &mut Node) { /// [`crate::distort::MAX_COPY_OBJECTS`] nodes in all, the art of opacity masks counted. Returns what /// the art costs after. fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { - let Some(members) = art.children().cloned() else { return art_cost(art) }; + let Some(mut members) = art.children().cloned() else { return art_cost(art) }; + // A layer's ruler guides are not copied: they stay above the copies, once. + let guides = members.split_off(Node::guide_run_start(&members)); let original = Node::group(art.id, members); let mut made = copy_cost(&original); let mut drawn = original.clone(); @@ -1014,6 +1016,7 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc:: // The last copy is the lowest. placements.reverse(); placements.push(Arc::new(original)); + placements.extend(guides); if let Some(children) = art.children_mut() { *children = placements; } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 95ef265cc9..be6c68ba74 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -348,7 +348,7 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { fn walk(n: &Node, opacity: f32, scopes: &[Scope], w: &mut Walk) -> Result<(), String> { let label = w.label; for c in n.children().into_iter().flatten() { - if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + if !c.visible || c.is_guide() { continue; } let opacity = opacity * c.opacity; diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index ad9495cf08..66ca2c1d72 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -191,7 +191,7 @@ impl Resolver<'_> { let mut changed = false; for c in children { self.spend(1)?; - if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + if !c.visible || c.is_guide() { continue; } if matches!(c.kind, NodeKind::Image(_)) { @@ -279,7 +279,7 @@ impl Resolver<'_> { kids = nest(n, cut, 0); } else { for c in n.children().into_iter().flatten() { - if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + if !c.visible || c.is_guide() { continue; } if let Some(c) = self.clipped(c, clip, depth + 1)? { diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c76492f454..7b6cdcc58b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -526,10 +526,12 @@ pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?; - if s.doc()?.doc.node(id).is_none() { - return Err(bad(command, format!("no such object id {value}"))); + match s.doc()?.doc.node(id) { + None => Err(bad(command, format!("no such object id {value}"))), + // The guide commands take ruler guides by index. + Some(n) if n.is_ruler_guide() => Err(bad(command, format!("id {value} is a ruler guide, not an object"))), + Some(_) => Ok(id), } - Ok(id) } /// `v` as exactly `N` finite numbers (`[x, y]`, `[x, y, width, height]`…): `None` when it isn't an @@ -689,10 +691,11 @@ pub(crate) fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result> { let compound = |id: NodeId| d.parent_of(id).filter(|p| d.node(*p).is_some_and(owns)); let mut out = vec![]; for id in ids { - let Some(n) = d.node(*id) else { continue }; + // A ruler guide has no appearance to paint, given or in a layer. + let Some(n) = d.node(*id).filter(|n| !n.is_ruler_guide()) else { continue }; match &n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } if !n.is_compound_shape() => n.walk(&mut |c| { - if !c.is_container() || owns(c) { + if (!c.is_container() && !c.is_ruler_guide()) || owns(c) { out.push(c.id) } }), @@ -742,6 +745,7 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap< /// The most presets one presets file imports: each is looked up among the saved ones. pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; +pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index dfb450faed..4fd1f1ce96 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -185,7 +185,7 @@ fn objects<'a>(d: &'a Document, scope: &Scope) -> Vec<(&'a Node, Place)> { } fn visit<'a>(n: &'a Node, place: Place, out: &mut Vec<(&'a Node, Place)>) { - if !n.visible || n.locked || out.len() >= MAX_OBJECTS { + if !n.visible || n.locked || n.is_ruler_guide() || out.len() >= MAX_OBJECTS { return; } let inner = |clip: bool, i: usize, top: bool| Place { clips: clip && i == 0, top, in_compound: false }; @@ -216,7 +216,7 @@ fn is_generated(n: &Node) -> bool { } fn is_guide_or_clip(n: &Node) -> bool { - matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) + n.is_guide() || matches!(n.kind, NodeKind::Path { clipping: true, .. }) } fn is_shape(n: &Node) -> bool { @@ -484,7 +484,7 @@ fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result 0.0 && (!self.text_only || matches!(n.kind, NodeKind::Text(_))); let mut counts = (white, usize::from(blacks && black.apply(n, spots))); - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { let (w, b) = self.overprint(Arc::make_mut(c), black, spots); counts = (counts.0 + w, counts.1 + b); } diff --git a/crates/engine/src/cmd/addons/document.rs b/crates/engine/src/cmd/addons/document.rs index fafdfc07dc..654138a1dd 100644 --- a/crates/engine/src/cmd/addons/document.rs +++ b/crates/engine/src/cmd/addons/document.rs @@ -164,11 +164,11 @@ fn export_all_open(s: &mut Session, p: &Value) -> Result { } /// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds -/// no layers). +/// no layers, and no ruler guides: a layer's are left out). fn without_layers(n: &Node) -> Node { let mut n = n.clone(); if let NodeKind::Layer { children, clip, .. } = &n.kind { - n.kind = NodeKind::Group { children: children.clone(), clip: *clip }; + n.kind = NodeKind::Group { children: children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(), clip: *clip }; } if let Some(children) = n.children_mut() { for c in children.iter_mut().filter(|c| matches!(c.kind, NodeKind::Layer { .. })) { @@ -203,8 +203,10 @@ fn symbols_from_layers(s: &mut Session, p: &Value) -> Result { let (mut made, mut skipped) = (vec![], vec![]); for layer in layers.iter().filter_map(|id| d.layers.iter().find(|l| l.id == *id)) { let name = layer.display_name(); - let roots: Vec = layer.children().map(|c| c.iter().map(|n| n.id).collect()).unwrap_or_default(); - let art = match layer.children().map(Vec::as_slice).unwrap_or_default() { + // The layer's ruler guides stay where they are. + let children: Vec> = layer.children().into_iter().flatten().filter(|n| !n.is_ruler_guide()).cloned().collect(); + let roots: Vec = children.iter().map(|n| n.id).collect(); + let art = match children.as_slice() { [] => None, [one] => Some(without_layers(one)), many => Some(without_layers(&Node::group(NodeId(0), many.to_vec()))), diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 024358adfe..2dfeb497d0 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -1189,7 +1189,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { .iter() .filter_map(|id| d.node(*id)) .filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) - .flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)) + .flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked && !c.is_ruler_guide()).map(|c| c.id)) .collect(); if ids.is_empty() { return Err(bad("appearance.targetContents", "select a group or layer with visible, unlocked members")); diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 0b57565af4..1ebde5c026 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -116,13 +116,15 @@ pub(crate) fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut V let Some(n) = doc.node(id) else { return }; if n.is_layer() { if let Some(kids) = n.children() { - for c in kids { + for c in kids.iter().filter(|c| !c.is_ruler_guide()) { art_under(doc, c.id, out); } } return; } - out.push(id); + if !n.is_ruler_guide() { + out.push(id); + } } fn add(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index 32edc44f86..888e450085 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -137,7 +137,7 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { /// stroke has a Convert to Shape or Outline Object effect. fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option { Some(match &n.kind { - NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")), + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => bad(cmd, format!("{key} cannot be set on a guide")), NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None, NodeKind::Image(_) => { let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) }; diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 5ff09dc284..9e52454de1 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -322,7 +322,18 @@ impl Clipboard { /// Every object of `src` (each top-level layer's contents) with its resources, for Place and /// pasting outside SVG. Their layers don't count for Paste Remembers Layers. pub fn from_document(src: &Document) -> Self { - let nodes = src.layers.iter().flat_map(|l| l.children().into_iter().flatten()).map(|n| (**n).clone()).collect(); + // Ruler guides are not objects: the ones of `src`'s layers and sublayers stay behind. + let nodes = src + .layers + .iter() + .flat_map(|l| l.children().into_iter().flatten()) + .filter(|n| !n.is_ruler_guide()) + .map(|n| { + let mut n = (**n).clone(); + n.strip_ruler_guides(); + n + }) + .collect(); Self::gather(src, nodes) } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index ac082d7a05..65f1056895 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -462,7 +462,7 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - pub(crate) fn left_out(d: &Document, iso: Option, roots: &[NodeId]) -> Vec<(NodeId, bool)> { fn visit(n: &Node, hidden: bool, locked: bool, all: &HashSet, board_locked: &dyn Fn(&Node) -> bool, out: &mut Vec<(NodeId, bool)>) { for c in n.children().into_iter().flatten() { - if c.is_template() { + if c.is_template() || c.is_ruler_guide() { continue; } let (hidden, locked) = (hidden || !c.visible, locked || c.locked); diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 03b171de71..da7dc42ece 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -582,7 +582,7 @@ fn map_more_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proo } } if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { + for c in ch.iter_mut().filter(|c| !c.is_ruler_guide()) { map_more_colors(Arc::make_mut(c), f); } } diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index e6e75c1f3a..cb1bb86fbd 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -370,7 +370,8 @@ fn info(s: &mut Session, p: &Value) -> Result { return; } let k = match &n.kind { - NodeKind::Layer { .. } => None, + // The Guides row counts paths made into guides; ruler guides are not objects. + NodeKind::Layer { .. } | NodeKind::RulerGuide { .. } => None, NodeKind::Group { clip: true, .. } => Some("clipGroups"), NodeKind::Group { .. } => Some("groups"), NodeKind::Path { guide: true, .. } => Some("guides"), diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 5f8508f4e9..db792ab1c6 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -666,7 +666,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { if (index.is_some() || ids.is_some()) && s.menu.guides_locked { return Err(EngineError::Other("guides are locked".into())); } - let (rulers, paths): (Vec, Vec) = if index.is_some() || ids.is_some() { + let (rulers, paths): (Vec, Vec) = if index.is_some() || ids.is_some() { let rulers = match index { Some(v) => vec![editable_guide_index(s, Some(v), C)?], None => vec![], @@ -689,10 +689,10 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let all = guide_paths(&st.doc); let sel: Vec = all.iter().copied().filter(|id| st.selection.contains(*id)).collect(); - let rulers: Vec = if s.menu.guides_locked { + let rulers: Vec = if s.menu.guides_locked { vec![] } else { - st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect() + st.selection.guides.iter().copied().filter(|id| st.doc.ruler_guide(*id).is_some_and(|g| st.doc.guide_editable(&g))).collect() }; let editable = st.doc.editable(); if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| editable(*id)).collect()) } else { (rulers, sel) } @@ -702,8 +702,8 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)); let mut lines = Vec::with_capacity(rulers.len()); for &i in &rulers { - let Some(g) = st.doc.guides.get(i) else { continue }; - let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })); + let Some(g) = st.doc.ruler_guide(i) else { continue }; + let span = st.doc.guide_span(&g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })); let Some((a, b)) = span else { continue }; let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) }; let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) { @@ -729,7 +729,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?; made.push(id); } - d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i)); + d.retain_guides(sel, |g| !lines.iter().any(|l| Some(l.0) == g.id)); sel.set(paths.iter().copied().chain(made)); Ok(()) })?; @@ -745,12 +745,12 @@ fn lock_guides(s: &mut Session, p: &Value) -> Result { fn clear_guides(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let paths = guide_paths(&st.doc); - let n = paths.len() + st.doc.guides.len(); + let n = paths.len() + st.doc.ruler_guides().len(); if n == 0 { return Ok(json!({ "count": 0 })); } - s.edit("Clear Guides", |d, _| { - d.guides.clear(); + s.edit("Clear Guides", |d, sel| { + d.retain_guides(sel, |_| false); let mut gone = Beside::replacing(d, &paths); for id in &paths { if d.node(*id).is_some() { @@ -780,17 +780,29 @@ fn guide_add(s: &mut Session, p: &Value) -> Result { // Where new art goes (refused when no layer is shown and unlocked), or that group's layer. None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }), }; - let i = s.edit("New Guide", |d, _| { - d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) }); - Ok(d.guides.len() - 1) - })?; + let id = s.edit("New Guide", |d, _| Ok(d.add_ruler_guide(&Guide { artboard, layer, ..Guide::new(vertical, pos) })))?; + let i = guide_indexes(&s.doc()?.doc, &[id]).first().copied(); Ok(json!({ "index": i })) } +/// The ids of the active document's ruler guides by index: the order of `guide.list` (ascending +/// id). +pub(crate) fn guide_ids(d: &vectorcraft_doc::Document) -> Vec { + d.ruler_guides().into_iter().filter_map(|g| g.id).collect() +} + +/// The `guide.list` indexes of ruler guides `ids`, in the order of `ids` (an id that is not a ruler +/// guide is left out). +pub fn guide_indexes(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { + let all = guide_ids(d); + ids.iter().filter_map(|id| all.binary_search(id).ok()).collect() +} + fn guide_list(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let row = |(i, g): (usize, &Guide)| { - let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": st.selection.guides.contains(&i) }); + let selected = g.id.is_some_and(|id| st.selection.guides.contains(&id)); + let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": selected }); if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) { r["artboard"] = json!(ab); } @@ -801,31 +813,38 @@ fn guide_list(s: &mut Session, _: &Value) -> Result { r["editable"] = json!(st.doc.guide_editable(g)); r }; - Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect())) + Ok(Value::Array(st.doc.ruler_guides().iter().enumerate().map(row).collect())) } -/// Ruler guide `index` of the active document (`index` validated). -fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { +/// The id of ruler guide `index` in `ids` ([`guide_ids`]; `index` validated). +fn guide_index_in(ids: &[NodeId], v: Option<&Value>, cmd: &str) -> Result { let i = v.and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "an index must be a whole number"))?; - if i >= s.doc()?.doc.guides.len() { - return Err(bad(cmd, format!("no guide {i}"))); + ids.get(i).copied().ok_or_else(|| bad(cmd, format!("no guide {i}"))) +} + +/// The id of ruler guide `index` of the active document (`index` validated). +fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + guide_index_in(&guide_ids(&s.doc()?.doc), v, cmd) +} + +/// Is ruler guide `id` one that can be moved or deleted: not on a hidden or locked layer? +fn check_editable_guide(s: &Session, id: NodeId) -> Result { + let d = &s.doc()?.doc; + if !d.ruler_guide(id).is_some_and(|g| d.guide_editable(&g)) { + return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); } - Ok(i) + Ok(id) } /// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer. -fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { - let i = guide_index(s, v, cmd)?; - let d = &s.doc()?.doc; - if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) { - return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); - } - Ok(i) +fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + check_editable_guide(s, guide_index(s, v, cmd)?) } fn guide_select(s: &mut Session, p: &Value) -> Result { let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?; - let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::>>()?; + let ids = guide_ids(&s.doc()?.doc); + let picked = list.iter().map(|v| guide_index_in(&ids, Some(v), "guide.select")).collect::>>()?; if bool_or(p, "toggle", false) { // Added to (or taken from) what is selected, the art included. s.select(|_, sel| { @@ -836,12 +855,13 @@ fn guide_select(s: &mut Session, p: &Value) -> Result { } else { s.select(|_, sel| sel.set_guides(picked))?; } - Ok(json!({ "selected": s.doc()?.selection.guides })) + let st = s.doc()?; + Ok(json!({ "selected": guide_indexes(&st.doc, &st.selection.guides) })) } /// Delete ruler guide `index`, else the selected guides, in one undo step → {count}. pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { - let gone: Vec = match p.get("index") { + let gone: Vec = match p.get("index") { Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?], None => s.doc()?.selection.guides.clone(), }; @@ -850,7 +870,7 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { } let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" }; s.edit(label, |d, sel| { - d.retain_guides(sel, |i, _| !gone.contains(&i)); + d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id))); Ok(()) })?; Ok(json!({ "count": gone.len() })) @@ -866,18 +886,22 @@ fn guide_set_layer(s: &mut Session, p: &Value) -> Result { if super::layer::locked_within(d, layer) { return Err(EngineError::Other("the layer or group is locked".into())); } - let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { + let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { (Some(v), _) => vec![editable_guide_index(s, Some(v), C)?], - (None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::>>()?, + (None, Some(list)) => { + let ids = guide_ids(&s.doc()?.doc); + list.iter().take(10_000).map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()? + } (None, None) => s.doc()?.selection.guides.clone(), }; if moving.is_empty() { return Err(bad(C, "no guide selected (or give an index)")); } s.edit("Move Guide to Layer", |d, _| { - for i in &moving { - if let Some(g) = d.guides.get_mut(*i) { - g.layer = Some(layer); + // Each keeps its id (and so its index), at the top of the layer. + for id in &moving { + if d.parent_of(*id) != Some(layer) && d.ruler_guide(*id).is_some() { + d.move_node(*id, Some(layer), usize::MAX)?; } } Ok(()) @@ -891,9 +915,7 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { let i = editable_guide_index(s, Some(v), "guide.move")?; let pos = f64_req(p, "pos", "guide.move")?; s.edit("Move Guide", |d, _| { - if let Some(g) = d.guides.get_mut(i) { - g.pos = pos; - } + d.edit_ruler_guide(i, |g| g.pos = pos); Ok(()) })?; return ok(); @@ -917,18 +939,14 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { s.edit(if copy { "Copy Guide" } else { "Move Guide" }, |d, sel| { let mut copies = vec![]; for &i in &moving { - let Some(g) = d.guides.get_mut(i) else { continue }; - let moved = g.moved(Vec2::new(dx, dy)); if copy { - copies.push(moved); + copies.extend(d.ruler_guide(i).map(|g| d.add_ruler_guide(&g.moved(Vec2::new(dx, dy))))); } else { - *g = moved; + d.edit_ruler_guide(i, |g| *g = g.moved(Vec2::new(dx, dy))); } } if copy { - let first = d.guides.len(); - d.guides.extend(copies); - sel.set_guides(first..d.guides.len()); + sel.set_guides(copies); } Ok(()) })?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3558f1b569..7a8a91f0af 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -991,8 +991,8 @@ fn copy(s: &mut Session, p: &Value) -> Result { .selection .guides .iter() - .filter_map(|i| st.doc.guides.get(*i)) - .map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, ..g.clone() }) + .filter_map(|i| st.doc.ruler_guide(*i)) + .map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, id: None, ..g }) .collect(); let roots = if st.selection.is_empty() && !guides.is_empty() { vec![] } else { targets }; let mut clip = Clipboard::copy(st, &roots, direct); @@ -1004,7 +1004,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { // Guides alone: the artboard the first one belongs to, or crosses, is where in-place pastes // measure from, as the objects' artboard is. if clip.nodes.is_empty() - && let Some(g) = st.selection.guides.first().and_then(|i| st.doc.guides.get(*i)) + && let Some(g) = st.selection.guides.first().and_then(|i| st.doc.ruler_guide(*i)) { let board = g.artboard.and_then(|id| st.doc.artboards.iter().find(|a| a.id == id)).or_else(|| { st.doc @@ -1053,7 +1053,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { s.edit("Cut", |d, selection| { // The selected ruler guides were copied too (#3226): they go in the same undo step. let gone = selection.guides.clone(); - d.retain_guides(selection, |i, _| !gone.contains(&i)); + d.retain_guides(selection, |g| !g.id.is_some_and(|id| gone.contains(&id))); let mut survivors = vec![]; let mut pieces = super::pathops::Beside::replacing(d, roots); for id in roots { @@ -1153,7 +1153,7 @@ fn clear(s: &mut Session, p: &Value) -> Result { } if with_guides { let gone = sel.guides.clone(); - d.retain_guides(sel, |i, _| !gone.contains(&i)); + d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id))); } if is_explicit { for id in &ids { @@ -1438,8 +1438,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho for g in &clip.guides { let pos = g.pos + if g.vertical { dv.x } else { dv.y }; if pos.is_finite() { - sel.guides.push(d.guides.len()); - d.guides.push(vectorcraft_doc::Guide { pos, layer: guide_layer, ..g.clone() }); + sel.guides.push(d.add_ruler_guide(&vectorcraft_doc::Guide { pos, layer: guide_layer, ..g.clone() })); } } } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 89d4f5011c..30d927a966 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -889,6 +889,10 @@ fn expand_pieces( let mut seen = std::collections::HashSet::from([id]); let mut expanded = std::collections::HashMap::new(); for c in m.children_mut().into_iter().flatten() { + // A layer's ruler guides are not art: they keep their ids and expand to nothing. + if c.is_ruler_guide() { + continue; + } let c = Arc::make_mut(c); let mut listed = None; if all || c.id == id { @@ -1084,6 +1088,11 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // The place of each member in the walk of `n`: after `n` and the members before it. let mut at = 1; for c in n.children_mut().into_iter().flatten() { + if c.is_ruler_guide() { + // A layer's ruler guide expands to nothing, but the walk of `n` still visits it. + at += 1; + continue; + } let from = out.len(); match members.remove(&c.id) { Some(listed) => out.extend(listed), @@ -1097,7 +1106,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br for (_, place) in &mut out[from..] { *place += at; } - at += c.count(); + // Every node the walk visits, ruler guides in a sublayer too (`Node::count` leaves them out). + c.walk(&mut |_| at += 1); } } changed.then_some(n) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index ffb58d9d4b..8dce25b11a 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -534,7 +534,7 @@ fn expand_piece_leaves( } return; } - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { expand_piece_leaves(d, Arc::make_mut(c), left, expanded, expand); } } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 1b217e6c2d..58fff3cad1 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -306,10 +306,10 @@ pub(super) fn outlined_if_asked(mut doc: Document, warnings: Vec, opts: (doc, warnings) } -/// Does `doc` hold no art: only layers, with nothing in them? +/// Does `doc` hold no art: only layers, with nothing in them but ruler guides? fn draws_nothing(doc: &Document) -> bool { let mut art = false; - doc.walk(|n| art |= !n.is_layer()); + doc.walk(|n| art |= !n.is_layer() && !n.is_ruler_guide()); !art } diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 409db3f1b2..c1366dba3c 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -621,7 +621,7 @@ pub(crate) fn flatten_expanded(doc: &Document, o: &FlattenOptions) -> Result>()); + // A sublayer that becomes flat art gives its ruler guides to the layer that held it. + let guides = held_guides(d, plan.flat.iter().flat_map(|f| f.roots.iter().copied())); for f in plan.flat { // A group always has objects. let Some(top) = place_of(d, &f.roots) else { continue }; @@ -703,6 +705,9 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan if !flat.is_empty() { d.insert_beside(flat, true)?; } + for (layer, guide) in guides { + d.insert(Some(layer), usize::MAX, guide)?; + } let brushes = if o.strokes_to_outlines { vectorcraft_brush::library(d) } else { vec![] }; for mut id in plan.kept { if o.text_to_outlines { @@ -747,6 +752,23 @@ fn place_of(d: &Document, roots: &[NodeId]) -> Option { Some(place.map_or(top, |(r, _)| *r)) } +/// The ruler guides of each of `roots` that is a sublayer (in its sublayers too), with the layer +/// that holds it, in order. Other objects hold none. +fn held_guides(d: &Document, roots: impl IntoIterator) -> Vec<(NodeId, Node)> { + let mut out = vec![]; + for id in roots { + let (Some(n), Some(parent)) = (d.node(id), d.parent_of(id)) else { continue }; + if n.holds_ruler_guides() { + n.walk(&mut |c| { + if c.is_ruler_guide() { + out.push((parent, c.clone())); + } + }); + } + } + out +} + /// An image of `r` (in a clip group when it is clipped). fn raster_node(d: &mut Document, r: Raster) -> Node { let key = unique_key(d, "flattened"); @@ -829,3 +851,30 @@ fn drop_overprints(n: &mut Node) { } } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::Session; + + #[test] + fn a_flattened_sublayer_gives_its_ruler_guides_to_its_layer() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let sub = NodeId(s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()["id"].as_u64().unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + s.execute("transparency.set", &json!({"ids": [r], "opacity": 50})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 5.0, artboard: None }); + let g = d.insert(Some(sub), usize::MAX, g).unwrap(); + let flat = flatten_document(&s.doc().unwrap().doc, &FlattenOptions::default()).unwrap().unwrap(); + assert!(flat.node(sub).is_none(), "the sublayer became flat art"); + assert!(flat.node(g).is_some_and(Node::is_ruler_guide), "its guide is kept"); + assert_eq!(flat.parent_of(g), Some(layer)); + assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g)); + } +} diff --git a/crates/engine/src/cmd/flatten/report.rs b/crates/engine/src/cmd/flatten/report.rs index 3cac1ffe86..41b08b7a05 100644 --- a/crates/engine/src/cmd/flatten/report.rs +++ b/crates/engine/src/cmd/flatten/report.rs @@ -12,7 +12,9 @@ pub(super) fn target_roots(s: &Session, p: &Value) -> Result> { let mut ids = vec![]; for id in targets(s, p)? { match d.node(id) { - Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().map(|c| c.id)), + // Ruler guides are not art: they stay where they are. + Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).map(|c| c.id)), + Some(n) if n.is_ruler_guide() => {} Some(_) => ids.push(id), None => {} } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 07571ad320..49b1015bf4 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -243,7 +243,8 @@ pub(crate) fn accepts(d: &Document, parent: Option, n: &Node) -> bool { None => n.is_layer(), Some(p) => match d.node(p).map(|x| &x.kind) { Some(NodeKind::Layer { .. }) => true, - Some(NodeKind::Group { .. }) => !n.is_layer(), + // A ruler guide stays directly in a layer or sublayer. + Some(NodeKind::Group { .. }) => !n.is_layer() && !n.is_ruler_guide(), _ => false, }, } @@ -478,8 +479,12 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result { super::object::release_clip(d, id); return Ok(false); } - let top = - n.children().and_then(|c| c.last()).map(|c| c.id).ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?; + // The topmost object: a layer's ruler guides above its art do not clip. + let top = n + .children() + .and_then(|c| c.iter().rev().find(|c| !c.is_ruler_guide())) + .map(|c| c.id) + .ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?; super::object::make_clipping_path(d, top)?; d.move_node(top, Some(id), 0)?; if let Some(n) = d.node_mut(id) { @@ -556,9 +561,8 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("a document needs at least one layer".into())); } s.edit("Delete Selection", |d, sel| { - d.remove_many(&ids)?; // The guides on the deleted layers go with them. - d.drop_guides_of_deleted_layers(sel); + d.remove_many(&ids)?; sel.prune(d); Ok(()) })?; @@ -577,10 +581,10 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result { return Err(bad("layer.duplicate", "no such rows")); } let copies = s.edit("Duplicate Selection", |d, _| { - super::edit::copy_above(d, &ids, |d, n, c| { + // A copied layer's ruler guides are copied with it. + super::edit::copy_above(d, &ids, |_, n, c| { if n.is_layer() { c.name = Some(format!("{} copy", n.display_name())); - d.copy_layer_guides(n, c); } }) })?; @@ -706,6 +710,9 @@ fn select_art(s: &mut Session, p: &Value) -> Result { vec![] } else if n.is_layer() { n.layer_art(true) + } else if n.is_ruler_guide() { + // A ruler guide row selects the guide through `guide.select`, not as art. + vec![] } else { vec![id] }; @@ -784,11 +791,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { let mut nodes = vec![]; for id in &ids { let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; - let c = d.reid(&n); - if n.is_layer() { - d.copy_layer_guides(&n, &c); - } - nodes.push(Arc::new(c)); + // A copied layer's ruler guides are copied with it. + nodes.push(Arc::new(d.reid(&n))); } nodes } else { @@ -810,6 +814,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { let ch = d.children_mut(parent)?; let index = index.min(ch.len()); ch.splice(index..index, nodes); + // A layer's ruler guides stay above its art, each in the order it now has. + ch.sort_by_key(|c| c.is_ruler_guide()); // Copied type whose layout caches `reid` dropped has them again. if copy { super::typecmd::refresh_bounds_of(d, &out); @@ -972,7 +978,7 @@ fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indi for &i in indices.iter().rev() { let id = d.artboards.remove(i).id; d.retire_artboard_id(id); - d.retain_guides(sel, |_, g| g.artboard != Some(id)); + d.retain_guides(sel, |g| g.artboard != Some(id)); } Ok(()) } diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index 0a81a947ec..95c152e959 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -255,9 +255,9 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { let mut all = std::mem::take(ch); all.extend(taken); all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id))); + // Ruler guides (the merged layers' among them) stay above the art, each kind in that order. + all.sort_by_key(|c| c.is_ruler_guide()); *ch = all; - // The merged layers' guides go to it too. - d.rehome_guides(into); Ok(()) } @@ -318,9 +318,8 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let others: Vec = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect(); let hidden: Vec = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect(); s.edit("Flatten Artwork", |d, sel| { - d.remove_many(&hidden)?; // Hidden layers are discarded with their guides. - d.drop_guides_of_deleted_layers(sel); + d.remove_many(&hidden)?; merge_into(d, into, &others)?; sel.prune(d); sel.deselect_uneditable(d); @@ -349,8 +348,8 @@ fn release(s: &mut Session, p: &Value) -> Result { NodeKind::Group { .. } => true, _ => return Err(bad(C, "release a layer or a group")), }; - // Its objects, bottom first (sublayers stay as they are). - let objects: Vec = n.children().into_iter().flatten().filter(|c| !c.is_layer()).map(|c| c.id).collect(); + // Its objects, bottom first (sublayers and ruler guides stay as they are). + let objects: Vec = n.children().into_iter().flatten().filter(|c| !c.is_layer() && !c.is_ruler_guide()).map(|c| c.id).collect(); if objects.is_empty() { return Err(EngineError::Other("there are no objects to release".into())); } @@ -386,7 +385,8 @@ fn release(s: &mut Session, p: &Value) -> Result { } let new: Vec = layers.iter().map(|l| l.id).collect(); let ch = d.children_mut(Some(parent))?; - let at = index.min(ch.len()); + // Below the parent's ruler guides. + let at = index.min(Node::guide_run_start(ch)); ch.splice(at..at, layers); // Copied type whose layout caches `reid` dropped has them again. if build { diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index f2d322a39c..debd0db684 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -259,10 +259,12 @@ pub(crate) fn follow_history(st: &mut crate::DocState, was: Option) { /// mode when the object or the layer is gone (e.g. deleted). pub(crate) fn sync(d: &mut Document) { let Some(me) = d.mask_edit else { return }; - let art: Option>> = d.node(me.layer).and_then(|l| l.children().cloned()); + // The layer's art: ruler guides on it are not part of the mask. + let art: Option>> = + d.node(me.layer).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()); let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.extras().mask.is_some())) else { d.mask_edit = None; - let _ = d.remove(me.layer); + let _ = d.remove_edit_layer(me.layer); return; }; let new_art = match art.as_slice() { diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index b32275d1b7..c3a2898571 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -102,15 +102,15 @@ fn items_bounds(d: &Document, ids: &[NodeId], m: Measure) -> Vec<(NodeId, Rect)> ids.iter().filter_map(|id| Some((*id, m.of(d, *id)?))).collect() } -/// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position) +/// Move each object of `moves` by its offset and each ruler guide of `guides` (id, position) /// to its position, as undo step `label`; pattern tiles move too when [`transform_patterns`] says /// so for `ids`. When every move is a rounding error (1e-9 pt or less on both axes), the document /// and its undo history stay as they are. -fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(usize, f64)]) -> Result { +fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(NodeId, f64)]) -> Result { let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() }; - let guides: Vec<(usize, f64)> = { + let guides: Vec<(NodeId, f64)> = { let d = &s.doc()?.doc; - guides.iter().copied().filter(|&(i, pos)| d.guides.get(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect() + guides.iter().copied().filter(|&(i, pos)| d.ruler_guide(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect() }; if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) && guides.is_empty() { return ok(); @@ -124,9 +124,7 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: & super::pixelgrid::keep_compound_aligned(d, *id, sc); } for &(i, pos) in &guides { - if let Some(g) = d.guides.get_mut(i) { - g.pos = pos; - } + d.edit_ruler_guide(i, |g| g.pos = pos); } Ok(()) })?; @@ -143,7 +141,7 @@ fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> { /// [`vectorcraft_doc::Selection::key_guide`]): its position, and whether it is vertical. fn key_guide(s: &Session, p: &Value) -> Option<(f64, bool)> { let st = s.doc().ok()?; - let g = st.doc.guides.get(st.selection.key_guide?)?; + let g = st.doc.ruler_guide(st.selection.key_guide?)?; (align_to(s, p) == Some("key")).then_some((g.pos, g.vertical)) } @@ -164,7 +162,7 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], m: Measure, own: Opti /// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to /// Selection when no objects are selected. fn selected_guides_bounds(st: &crate::DocState) -> Option { - st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option, g| { + st.selection.guides.iter().filter_map(|&i| st.doc.ruler_guide(i)).fold(None, |acc: Option, g| { let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) }; Some(acc.map(|a| a.union(r)).unwrap_or(r)) }) @@ -264,7 +262,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result { /// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put. /// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are /// selected; with objects in the selection they stay put. -fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { +fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { let st = s.doc()?; let to = align_to(s, p); let move_guides = match to { @@ -290,7 +288,7 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str> .guides .iter() .filter(|&&i| !skip_key || Some(i) != st.selection.key_guide) - .filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?))) + .filter_map(|&i| Some((i, at(st.doc.ruler_guide(i)?.vertical)?))) .collect()) } @@ -361,7 +359,7 @@ pub(super) fn can_distribute(s: &Session) -> std::result::Result<(), String> { #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Spaced { Object(NodeId), - Guide(usize), + Guide(NodeId), } /// The selected objects `ids` with their bounds (measured by `m`), and the selected @@ -371,7 +369,7 @@ fn spaced_items(s: &Session, ids: &[NodeId], m: Measure, horiz: bool) -> Result< let st = s.doc()?; let objects = items_bounds(&st.doc, ids, m).into_iter().map(|(id, r)| (Spaced::Object(id), r)); let guides = st.selection.guides.iter().filter_map(|&i| { - let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?; + let g = st.doc.ruler_guide(i).filter(|g| g.vertical == horiz)?; Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos))) }); Ok(objects.chain(guides).collect()) @@ -404,7 +402,7 @@ fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: for &(it, delta) in deltas { match it { Spaced::Object(id) => moves.push((id, along(delta))), - Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))), + Spaced::Guide(i) => guides.extend(d.ruler_guide(i).map(|g| (i, g.pos + delta))), } } apply_moves(s, label, p, ids, &moves, &guides) diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 7aef60fca0..ff5b950d03 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -39,7 +39,9 @@ pub(super) fn arrange(s: &mut Session, how: Arrange) -> Result { group.retain(|id| d.position(*id).is_some_and(|(_, idx, _)| idx >= floor)); } for (k, id) in group.iter().enumerate() { - let Some((_, idx, len)) = d.position(*id) else { continue }; + let Some((_, idx, _)) = d.position(*id) else { continue }; + // The parent's art, without the ruler guides that stay above it. + let len = d.children(par).map_or(0, |ch| Node::guide_run_start(ch)); if len <= floor { continue; } @@ -73,12 +75,16 @@ pub(super) fn send_to_current_layer(s: &mut Session, _: &Value) -> Result } // When the selected objects are already the topmost in the current layer, in paint order, the // document and its undo history stay as they are. - if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { + // (The layer's ruler guides stay above its art.) + let art = |c: &&std::sync::Arc| !c.is_ruler_guide(); + if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().filter(art).map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { return ok(); } s.edit("Send to Current Layer", |d, _| { let moved = d.remove_many(&ids)?; - d.children_mut(Some(layer))?.extend(moved); + let ch = d.children_mut(Some(layer))?; + let at = Node::guide_run_start(ch); + ch.splice(at..at, moved); Ok(()) })?; ok() @@ -176,7 +182,7 @@ fn selected_lock_hide_roots(s: &Session) -> Result> { fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { let mut v = vec![]; d.walk(|n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { v.push(n.id) } }); @@ -198,7 +204,7 @@ fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> if let Some(node) = d.node(root) { for child in node.children().into_iter().flatten() { child.walk(&mut |n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { ids.push(n.id); } }); @@ -222,7 +228,7 @@ fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> } for c in d.node(*g).and_then(Node::children).into_iter().flatten() { c.walk(&mut |n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { v.push(n.id) } }); diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 6184860979..d102068947 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -145,16 +145,14 @@ fn apply_transform_moving_guides( } None if copy => { let guide_copies = if with_guides { - let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); + let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect(); (!copies.is_empty()).then_some(copies) } else { None }; let new_ids = duplicate_in(d, sel, &ids, Affine::IDENTITY)?; if let Some(copies) = guide_copies { - let first = d.guides.len(); - d.guides.extend(copies); - sel.guides = (first..d.guides.len()).collect(); + sel.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect(); } new_ids } @@ -177,9 +175,7 @@ fn apply_transform_moving_guides( } if with_guides && (delta.x != 0.0 || delta.y != 0.0) { for i in sel.guides.clone() { - if let Some(g) = d.guides.get_mut(i) { - *g = g.moved(delta); - } + d.edit_ruler_guide(i, |g| *g = g.moved(delta)); } } Ok(targets) @@ -224,10 +220,8 @@ pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: boo let ids = duplicate_in(Arc::make_mut(&mut doc), &mut selection, roots, Affine::IDENTITY)?; if has_guide_copies { let d = Arc::make_mut(&mut doc); - let first = d.guides.len(); - let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); - d.guides.extend(copies); - selection.guides = (first..d.guides.len()).collect(); + let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect(); + selection.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect(); } let k = Arc::new(crate::PreviewCopies { from: it.doc.clone(), roots: roots.to_vec(), doc, selection, ids }); it.copies = Some(k.clone()); diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 7906a68e85..1e8d9ced62 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -214,7 +214,7 @@ fn attributes(s: &Session, p: &Value) -> Result { /// Overprint Black on `n` and its descendants; returns the objects changed. fn overprint_black_tree(n: &mut Node, op: &OverprintBlack, spots: &[String]) -> usize { let mut changed = usize::from(op.apply(n, spots)); - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { changed += overprint_black_tree(Arc::make_mut(c), op, spots); } changed diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 4cc4bbc2e4..5cdb240e29 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -242,6 +242,10 @@ fn same_in_proxy(a: &Paint, b: &Paint) -> bool { /// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and /// other containers inside them), a compound path for its parts. pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { + // A ruler guide has no paints. + if n.is_ruler_guide() { + return; + } let expand = match n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } => true, NodeKind::Compound { .. } => false, diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 9f50507aff..34810fa766 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -352,7 +352,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result Result { s.edit("Clear", |d, selection| { if with_guides { let gone = selection.guides.clone(); - d.retain_guides(selection, |i, _| !gone.contains(&i)); + d.retain_guides(selection, |g| !g.id.is_some_and(|id| gone.contains(&id))); } let targets: BTreeSet = whole.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer())).collect(); let whole_ids: Vec = targets diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 3da73c0385..5b9e25d354 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -1351,7 +1351,9 @@ fn split_into_grid(s: &mut Session, p: &Value) -> Result { let node = shape_node(d, c, Some(&top)); out.push(d.insert(par, idx + k, node)?); } - d.guides.extend(edges.iter().map(|&(vertical, pos)| vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) })); + for &(vertical, pos) in &edges { + d.add_ruler_guide(&vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) }); + } sel.set(out.iter().copied()); Ok(out) })?; diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index ef7820b71f..aeed4bf522 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -271,8 +271,9 @@ fn enter_edit(d: &mut Document, sel: &mut Selection, name: &str, original: Optio /// Leave editing mode: drop the layer, return the edit record. fn leave_edit(d: &mut Document, sel: &mut Selection) -> Result<(PatternEdit, Vec>)> { let pe = d.pattern_edit.take().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; - let art = match d.remove(pe.layer) { - Ok(l) => l.children().cloned().unwrap_or_default(), + // The tile art: ruler guides on the edit layer are not part of it and stay in the document. + let art = match d.remove_edit_layer(pe.layer) { + Ok(l) => l.children().into_iter().flatten().cloned().collect(), Err(_) => vec![], }; sel.clear(); @@ -427,7 +428,8 @@ fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { let name = s.edit("Save a Copy", |d, _| { let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?; - let art: Vec> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default(); + let art: Vec> = + d.node(pe.layer).into_iter().flat_map(|l| l.children().into_iter().flatten()).filter(|c| !c.is_ruler_guide()).cloned().collect(); let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern.trim()))); def.name = name.clone(); def.art = copies(d, &art).into_iter().map(Arc::new).collect(); @@ -481,7 +483,8 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { let out_name = new_name.clone().unwrap_or(name.clone()); s.edit("Pattern Options", |d, _| { // While editing, the tile art is the edit layer's. - let live_art: Option>> = edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children().cloned()); + let live_art: Option>> = + edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()); let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?; def.tile_type = tt; def.overlap = overlap; diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index db9bbf67d1..1ded346d86 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -377,7 +377,7 @@ fn placeable(l: &Node) -> bool { fn art_of<'a>(nodes: impl Iterator>) -> Vec { nodes .filter_map(|c| match &c.kind { - NodeKind::Path { guide: true, .. } => None, + _ if c.is_guide() => None, NodeKind::Layer { children, clip, .. } => placeable(c).then(|| { let mut g = (**c).clone(); g.kind = NodeKind::Group { children: art_of(children.iter()).into_iter().map(Arc::new).collect(), clip: *clip }; diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index 537658447b..8e1c3f5be8 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -48,12 +48,13 @@ pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[F Ok(Some(flatten_document(&printed, &o)?.unwrap_or_else(|| printed.into_owned()))) } -/// Leave guide paths out of `nodes` (the renderer draws them; they never print). +/// Leave guides out of `nodes`: guide paths (the renderer draws them; they never print) and ruler +/// guides. fn drop_guides(nodes: &mut Vec>) { fn has_guides(n: &Node) -> bool { - matches!(n.kind, NodeKind::Path { guide: true, .. }) || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c))) + n.is_guide() || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c))) } - nodes.retain(|n| !matches!(n.kind, NodeKind::Path { guide: true, .. })); + nodes.retain(|n| !n.is_guide()); for n in nodes.iter_mut().filter(|n| has_guides(n)) { if let Some(children) = Arc::make_mut(n).children_mut() { drop_guides(children); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index d41a4529b7..566a16dbfd 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -263,7 +263,7 @@ fn retain_effects(n: &mut Node, members: bool, keep: &dyn Fn(&Effect) -> bool) { it.effects_mut().retain(keep); } if members { - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { retain_effects(Arc::make_mut(c), true, keep); } } @@ -367,7 +367,9 @@ pub(crate) fn replace_with_image(mut v: Node, image: Node) -> Node { v.appearance = Default::default(); v.set_clips(false); if let Some(ch) = v.children_mut() { - *ch = vec![Arc::new(image)]; + // Its ruler guides stay above the image. + let guides = ch.split_off(Node::guide_run_start(ch)); + *ch = std::iter::once(Arc::new(image)).chain(guides).collect(); } v } diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 4ef1e6bc6a..25bf6db542 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -442,15 +442,16 @@ fn key(s: &mut Session, p: &Value) -> Result { if id.is_some() { return Err(bad("select.key", "give `id` or `guide`, not both")); } - if !sel.guides.contains(&g) { + let gid = super::docmenu::guide_ids(&s.doc()?.doc).get(g).copied(); + let Some(gid) = gid.filter(|id| sel.guides.contains(id)) else { return Err(bad("select.key", format!("guide {g} is not selected"))); - } + }; if sel.is_empty() { return Err(bad("select.key", "select objects to align to the guide")); } s.select(|_, sel| { sel.key = None; - sel.key_guide = Some(g); + sel.key_guide = Some(gid); })?; return key_result(s); } @@ -472,9 +473,9 @@ fn key(s: &mut Session, p: &Value) -> Result { /// `{key}`, with `guide` when a guide is the key. fn key_result(s: &Session) -> Result { - let sel = &s.doc()?.selection; - let mut out = json!({ "key": sel.key }); - if let Some(g) = sel.key_guide { + let st = s.doc()?; + let mut out = json!({ "key": st.selection.key }); + if let Some(g) = st.selection.key_guide.and_then(|id| super::docmenu::guide_indexes(&st.doc, &[id]).first().copied()) { out["guide"] = json!(g); } Ok(out) diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 5792c44fdf..06ec067934 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -340,8 +340,9 @@ fn release(s: &mut Session, _: &Value) -> Result { fn from_guides(s: &mut Session, _: &Value) -> Result { let d = &s.doc()?.doc; let region = d.slice_clip().ok_or_else(|| EngineError::Other("Create from Guides: the document has no artboard".into()))?; + let guides = d.ruler_guides(); let cuts = |vertical: bool, lo: f64, hi: f64| -> Vec { - let mut v: Vec = d.guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect(); + let mut v: Vec = guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect(); v.extend([lo, hi]); v.sort_by(f64::total_cmp); v.dedup(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d73f01eaae..d31031d5d4 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -153,7 +153,8 @@ fn descends(n: &Node, top: bool) -> bool { /// The objects whose appearance a style applied to `n` sets: `n`, or its painted contents. fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { match n.children() { - Some(ch) if descends(n, top) => ch.iter().for_each(|c| painted(c, false, out)), + // A layer's ruler guides have no appearance to set. + Some(ch) if descends(n, top) => ch.iter().filter(|c| !c.is_ruler_guide()).for_each(|c| painted(c, false, out)), _ => out.push(n), } } diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 22cfc74e9f..a5f677f5dd 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -248,7 +248,7 @@ pub(crate) fn leave(s: &mut Session, _: &Value) -> Result { /// the instance editing was entered from. The symbol already holds the layer's art ([`sync`]). pub(crate) fn finish(d: &mut Document, sel: &mut Selection) { let Some(se) = d.symbol_edit.take() else { return }; - let _ = d.remove(se.layer); + let _ = d.remove_edit_layer(se.layer); match se.instance.filter(|i| d.node(*i).is_some()) { Some(i) => sel.set([i]), None => sel.clear(), @@ -480,18 +480,24 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc], bool), new: /// an instance of the symbol itself. pub(crate) fn sync(before: &Document, d: &mut Document) -> Result<()> { let Some(se) = d.symbol_edit.clone() else { return Ok(()) }; - let art: Option>> = d.node(se.layer).and_then(|l| l.children().cloned()); + // The layer's art: ruler guides on it are not part of the symbol. + let art_of = |d: &Document| -> Option>> { + d.node(se.layer).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()) + }; + let art = art_of(d); let (Some(art), Some(index)) = (art, d.symbols.iter().position(|x| x.name == se.symbol)) else { d.symbol_edit = None; - let _ = d.remove(se.layer); + let _ = d.remove_edit_layer(se.layer); return Ok(()); }; // Entering, and edits that left the layer's art alone, write nothing. let same = |b: &[Arc]| b.len() == art.len() && b.iter().zip(&art).all(|(a, b)| Arc::ptr_eq(a, b)); let (old, old_group) = match before.symbol_edit.as_ref().filter(|b| b.layer == se.layer) { None => return Ok(()), - Some(_) if before.node(se.layer).and_then(|l| l.children()).is_some_and(|b| same(b)) => return Ok(()), - Some(b) => (before.node(se.layer).and_then(|l| l.children().cloned()).unwrap_or_default(), b.group), + Some(b) => match art_of(before) { + Some(old) if same(&old) => return Ok(()), + old => (old.unwrap_or_default(), b.group), + }, }; if art.iter().any(|n| holds_symbol(d, n, &se.symbol, &mut HashSet::new())) { return Err(EngineError::Other("a symbol can't hold an instance of itself".into())); diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index 6e89a7544d..c79ec0b86e 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -137,7 +137,7 @@ fn paintable(doc: &Document) -> Vec { if matches!(n.kind, NodeKind::Compound { .. }) { compounds.push(n.id); out.push(n.id); - } else if !n.is_container() { + } else if !n.is_container() && !n.is_ruler_guide() { out.push(n.id); } }); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index c474e25756..52ef35060d 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -112,6 +112,8 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts, symbols: &SymbolBoxes) -> _ => {} } if let Some(ch) = n.children() { + // A layer's ruler guides are listed by `guide.list`, not as its objects. + let ch: Vec<&std::sync::Arc> = ch.iter().filter(|c| !c.is_ruler_guide()).collect(); let total = ch.len(); let take_n = opts.child_limit.map_or(total, |l| l.min(total as u64) as usize); if opts.depth.is_none_or(|d| d > 0) { @@ -203,6 +205,10 @@ fn ancestors(d: &Document, id: vectorcraft_doc::NodeId) -> Vec { } fn collect_kind<'a>(n: &'a Node, kind: &str, path: &mut Vec, out: &mut Vec>) { + // Ruler guides are not objects (`guide.list` lists them). + if n.is_ruler_guide() { + return; + } if n.kind_label().eq_ignore_ascii_case(kind) { out.push((n, path.clone())); } @@ -295,7 +301,7 @@ fn document_base(s: &Session) -> Value { // The key object Align aligns to and Distribute Spacing spaces from (`select.key`). "key": st.selection.key.map(|k| k.0), // The ruler guide that is the key instead (`select.key {guide}`): an index into `guide.list`. - "keyGuide": st.selection.key_guide, + "keyGuide": st.selection.key_guide.and_then(|id| crate::cmd::guide_indexes(d, &[id]).first().copied()), "history": st.history.undo.iter().map(|h| h.label.clone()).collect::>(), "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::>(), "objects": d.node_count(), @@ -432,7 +438,8 @@ pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec, } fn walk(nodes: &[Arc], w: &mut Walk) { - for n in nodes.iter().rev() { + // Ruler guides are not objects (`guide.list` lists them). + for n in nodes.iter().rev().filter(|n| !n.is_ruler_guide()) { if w.filter.hits(n, &w.symbols) { w.ids.push(n.id); if (w.out.len() as u64) < w.limit { diff --git a/crates/engine/src/sanity.rs b/crates/engine/src/sanity.rs index b28cc23ca8..22e0ce956f 100644 --- a/crates/engine/src/sanity.rs +++ b/crates/engine/src/sanity.rs @@ -45,7 +45,7 @@ pub(crate) fn doc_sane(d: &Document, sel: &Selection) -> bool { && d.ruler_origin.x.abs() <= MAX_COORD && d.ruler_origin.y.abs() <= MAX_COORD && d.artboards.iter().all(|a| rect_in_range(a.rect)) - && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) + && d.ruler_guides().iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) && objects_in_range(d, &sel.objects) } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index e34eb711bd..63fbcd5a78 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -300,8 +300,8 @@ impl Session { cmd::compositefonts::filter_loaded(&mut doc); // Inline graphics in text take their size from their symbols' art (not saved). doc.resolve_inline_art(); - // Guides from files saved before guides had layers go on a layer. - doc.adopt_guides(); + // Ruler guides a reader left in the saved list become nodes on their layers. + doc.guides_into_nodes(); cmd::dimension::conform(&mut doc); self.reset_tool_for_doc_switch(); let mut st = DocState::new(doc, path); @@ -328,7 +328,7 @@ impl Session { doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); doc.resolve_inline_art(); - doc.adopt_guides(); + doc.guides_into_nodes(); cmd::dimension::conform(&mut doc); // In the back, type set in a font named like a composite font is measured when the document // comes to the front and its own composite fonts are registered, not in the front diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 34c4eed727..abe9121eff 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -99,6 +99,13 @@ impl Session { // the artboards the journal recorded need only a document too, whatever is selected and // whichever tool is chosen. let given = given_ids(spec.params, params); + // The guide commands take ruler guides by index; an object command given one's id is refused. + if let (Some(ids), Some(st)) = (&given, self.active()) + && let Some(guide) = ids.iter().find(|i| st.doc.node(**i).is_some_and(|n| n.is_ruler_guide())) + { + let value = guide.0; + return Err(cmd::bad(id, format!("id {value} is a ruler guide, not an object"))); + } let enabled = if (id == "edit.clear" && params.get("ids").is_some()) || (matches!(id, "edit.copy" | "edit.cut") && params.get("selectedArtboards").is_some()) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9c6de6a89e..bc0323246d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -215,6 +215,18 @@ fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { NodeId(r["id"].as_u64().unwrap()) } +/// The active document's selected ruler guides, by their `guide.list` indexes. +pub(crate) fn selected_guides(s: &Session) -> Vec { + let st = s.doc().unwrap(); + crate::cmd::guide_indexes(&st.doc, &st.selection.guides) +} + +/// The active document's key guide, by its `guide.list` index. +pub(crate) fn key_guide(s: &Session) -> Option { + let st = s.doc().unwrap(); + st.selection.key_guide.and_then(|k| crate::cmd::guide_indexes(&st.doc, &[k]).first().copied()) +} + #[test] fn create_and_undo_redo() { let mut s = session(); diff --git a/crates/engine/src/tests/constrain_tools.rs b/crates/engine/src/tests/constrain_tools.rs index 6a8317cfeb..a93965eb0b 100644 --- a/crates/engine/src/tests/constrain_tools.rs +++ b/crates/engine/src/tests/constrain_tools.rs @@ -78,7 +78,7 @@ fn constrain_angle_turns_the_arrow_keys() { let g = s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap()["index"].as_u64().unwrap(); s.execute("guide.select", &json!({"indexes": [g]})).unwrap(); s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); - let pos = s.doc().unwrap().doc.guides[g as usize].pos; + let pos = s.doc().unwrap().doc.ruler_guides()[g as usize].pos; assert!(close(pos, 60.0), "a vertical guide moves 10 right: {pos}"); } @@ -167,7 +167,7 @@ fn constrain_angle_keeps_guides_selected_with_art_on_the_page_axes() { let h = s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"].as_u64().unwrap() as usize; s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("guide.select", &json!({"indexes": [v, h], "toggle": true})).unwrap(); - let pos = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |s: &Session| s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); let at = centre(&s, id); s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); assert_eq!(centre(&s, id) - at, Vec2::new(0.0, -10.0), "the art moves along the turned axis"); diff --git a/crates/engine/src/tests/guide_clipboard.rs b/crates/engine/src/tests/guide_clipboard.rs index d509dfa206..4211511021 100644 --- a/crates/engine/src/tests/guide_clipboard.rs +++ b/crates/engine/src/tests/guide_clipboard.rs @@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value { /// The active document's guides: (vertical, pos, layer). fn guides(s: &Session) -> Vec<(bool, f64, Option)> { - s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos, g.layer)).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos, g.layer)).collect() } #[test] @@ -31,7 +31,7 @@ fn ruler_guides_copy_and_paste_into_another_document() { let r = run(&mut s, "edit.paste", json!({})); assert_eq!(r["guides"], 2); assert_eq!(guides(&s), [(true, 120.0, Some(layer)), (false, 80.0, Some(layer))], "where they were, not offset"); - assert_eq!(s.doc().unwrap().selection.guides, [0, 1]); + assert_eq!(super::selected_guides(&s), [0, 1]); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); run(&mut s, "edit.undo", json!({})); assert!(guides(&s).is_empty()); @@ -104,9 +104,9 @@ fn two_pastes_add_their_own_guides_on_the_current_layer_and_svg_has_none() { assert!(s.clipboard.flavour_types(true, true).is_empty(), "nor any other format (the app empties the system clipboard)"); let layer = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap()); run(&mut s, "edit.paste", json!({})); - assert_eq!(s.doc().unwrap().selection.guides, [1], "the first paste selects its own guide"); + assert_eq!(super::selected_guides(&s), [1], "the first paste selects its own guide"); run(&mut s, "edit.paste", json!({})); - assert_eq!(s.doc().unwrap().selection.guides, [2], "the second paste selects its own guide"); + assert_eq!(super::selected_guides(&s), [2], "the second paste selects its own guide"); let g = guides(&s); assert_eq!(g.len(), 3, "two pastes, two more guides: {g:?}"); assert_eq!((g[1].2, g[2].2), (Some(layer), Some(layer)), "on the layer new art goes to"); @@ -116,7 +116,7 @@ fn two_pastes_add_their_own_guides_on_the_current_layer_and_svg_has_none() { // Hiding that layer hides the guides pasted on it: they follow its visibility (and lock). run(&mut s, "layer.setProps", json!({"id": layer.0, "visible": false})); let st = s.doc().unwrap(); - let shown: Vec = st.doc.guides.iter().map(|g| st.doc.guide_shown(g)).collect(); + let shown: Vec = st.doc.ruler_guides().iter().map(|g| st.doc.guide_shown(g)).collect(); assert_eq!(shown, [true, false, false]); // With an object, the SVG holds the object and no guide. run(&mut s, "layer.setProps", json!({"id": layer.0, "visible": true})); diff --git a/crates/engine/src/tests/keyguide.rs b/crates/engine/src/tests/keyguide.rs index 09acdd726d..327c0b4b24 100644 --- a/crates/engine/src/tests/keyguide.rs +++ b/crates/engine/src/tests/keyguide.rs @@ -37,14 +37,14 @@ fn a_clicked_guide_is_the_key_and_align_lines_the_objects_up_on_it() { // Click an object, Shift-click the guide, then click the guide again. click(&mut s, 110.0, 110.0, false); click(&mut s, 500.0, 400.0, true); - assert_eq!((s.doc().unwrap().selection.objects.clone(), s.doc().unwrap().selection.guides.clone()), (vec![a], vec![0])); + assert_eq!((s.doc().unwrap().selection.objects.clone(), super::selected_guides(&s)), (vec![a], vec![0])); click(&mut s, 500.0, 400.0, false); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "the guide is the key"); + assert_eq!(super::key_guide(&s), Some(0), "the guide is the key"); assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["keyGuide"], 0); // Shift-click a third object: the guide stays the key. click(&mut s, 310.0, 260.0, true); assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0)); + assert_eq!(super::key_guide(&s), Some(0)); let (ya, yb) = (bounds(&s, a).y0, bounds(&s, b).y0); s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap(); // Both centred on the guide's x; a vertical guide fixes no y, so they keep theirs. @@ -52,12 +52,12 @@ fn a_clicked_guide_is_the_key_and_align_lines_the_objects_up_on_it() { let r = bounds(&s, id); assert!((r.center().x - 500.0).abs() < 1e-6 && (r.y0 - y).abs() < 1e-6, "{id:?}: {r:?}"); } - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 500.0, "the guide stays put"); + assert_eq!(positions(&s)[0], 500.0, "the guide stays put"); s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); assert!((bounds(&s, a).x0 - 500.0).abs() < 1e-6 && (bounds(&s, b).x0 - 500.0).abs() < 1e-6); // A click on the key guide again: no key. click(&mut s, 500.0, 400.0, false); - assert_eq!(s.doc().unwrap().selection.key_guide, None); + assert_eq!(super::key_guide(&s), None); } #[test] @@ -96,22 +96,22 @@ fn select_key_takes_only_a_selected_guide_with_objects() { s.execute("select.key", &json!({"guide": 1})).unwrap(); // An object key replaces the guide key, and the other way round. s.execute("select.key", &json!({"id": b.0})).unwrap(); - assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (Some(b), None)); + assert_eq!((s.doc().unwrap().selection.key, super::key_guide(&s)), (Some(b), None)); s.execute("select.key", &json!({"guide": 1})).unwrap(); - assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (None, Some(1))); + assert_eq!((s.doc().unwrap().selection.key, super::key_guide(&s)), (None, Some(1))); // Deleting a guide before it keeps the key on the same guide. s.execute("guide.remove", &json!({"index": 0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0)); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 600.0); + assert_eq!(super::key_guide(&s), Some(0)); + assert_eq!(positions(&s)[0], 600.0); // Taking the guide out of the selection, or every object, lets the key go. s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, None); + assert_eq!(super::key_guide(&s), None); s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); s.execute("select.key", &json!({"guide": 0})).unwrap(); s.execute("select.toggle", &json!({"id": a.0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "one object is enough"); + assert_eq!(super::key_guide(&s), Some(0), "one object is enough"); s.execute("select.toggle", &json!({"id": b.0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, None, "no object to align"); + assert_eq!(super::key_guide(&s), None, "no object to align"); } #[test] @@ -141,12 +141,12 @@ fn selected_guides_align_to_a_clicked_key_object() { click(&mut s, 110.0, 110.0, false); click(&mut s, 500.0, 300.0, true); click(&mut s, 300.0, 400.0, true); - assert_eq!(s.doc().unwrap().selection.guides, vec![0, 1]); + assert_eq!(super::selected_guides(&s), vec![0, 1]); click(&mut s, 110.0, 110.0, false); assert_eq!(s.doc().unwrap().selection.key, Some(a), "the object is the key"); let before = bounds(&s, a); s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap(); - let guides = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let guides = positions; assert_eq!(guides(&s), [120.0, 110.0], "the vertical guide on the centre's x, the horizontal one on its y"); assert_eq!(bounds(&s, a), before, "the key stays put"); s.execute("object.align", &json!({"horizontal": "right"})).unwrap(); @@ -174,15 +174,15 @@ fn guides_align_to_a_key_object_among_others_and_parallel_ones_to_a_key_guide() s.execute("select.key", &json!({"id": b.0})).unwrap(); s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); assert_eq!(bounds(&s, a).x0, 300.0); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0)); + assert!(positions(&s).iter().all(|p| *p == 300.0)); // Aligning to the selection leaves guides alone. s.execute("object.align", &json!({"horizontal": "right", "to": "selection"})).unwrap(); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0)); + assert!(positions(&s).iter().all(|p| *p == 300.0)); // A key guide stays put, and the other vertical guide lines up on it. s.execute("guide.move", &json!({"index": 1, "pos": 700})).unwrap(); s.execute("select.key", &json!({"guide": 1})).unwrap(); s.execute("object.align", &json!({"horizontal": "center"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(), [700.0, 700.0]); + assert_eq!(positions(&s), [700.0, 700.0]); } /// Objects 40 pt wide at `xs` (y 100) and a vertical guide at `pos` that is their key. @@ -208,7 +208,7 @@ fn distribute_with_a_spacing_spaces_the_objects_from_a_key_guide() { for (h, want) in [("left", [330.0, 340.0, 360.0]), ("center", [310.0, 320.0, 340.0]), ("right", [290.0, 300.0, 320.0])] { s.execute("object.distribute", &json!({"horizontal": h, "spacing": 10})).unwrap(); assert_eq!(lefts(&s, &ids), want, "{h}"); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0, "{h}: the guide stays put"); + assert_eq!(positions(&s)[0], 350.0, "{h}: the guide stays put"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(lefts(&s, &ids), [100.0, 300.0, 500.0], "{h}: one undo step"); } @@ -300,14 +300,14 @@ fn guides_alone(vertical: &[f64], horizontal: &[f64]) -> Session { /// Selects the objects `ids` and the ruler guides `guides`, and nothing else. fn select_with_guides(s: &mut Session, ids: &[NodeId], guides: &[usize]) { s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); - let now = s.doc().unwrap().selection.guides.clone(); - let flip: Vec = (0..s.doc().unwrap().doc.guides.len()).filter(|i| now.contains(i) != guides.contains(i)).collect(); + let now = super::selected_guides(s); + let flip: Vec = (0..positions(s).len()).filter(|i| now.contains(i) != guides.contains(i)).collect(); s.execute("guide.select", &json!({"indexes": flip, "toggle": true})).unwrap(); assert_eq!((s.doc().unwrap().selection.objects.len(), s.doc().unwrap().selection.guides.len()), (ids.len(), guides.len())); } fn positions(s: &Session) -> Vec { - s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect() } #[test] diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index e1def8a6fa..e9a30c57e1 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -3686,7 +3686,7 @@ fn make_release_clear_guides() { s.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap(); let n = undo_len(&s); assert_eq!(s.execute("view.guides.clear", &json!({})).unwrap()["count"], 2); - assert!(s.doc().unwrap().doc.guides.is_empty()); + assert!(s.doc().unwrap().doc.ruler_guides().is_empty()); assert!(s.doc().unwrap().doc.node(a).is_none()); assert_eq!(undo_len(&s), n + 1); } @@ -3751,13 +3751,13 @@ fn ruler_guides_and_lock() { let mut s = session(); assert_eq!(s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"], 0); s.execute("guide.move", &json!({"index": 0, "pos": 70})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 70.0); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].pos, 70.0); assert_eq!(s.execute("view.guides.lock", &json!({})).unwrap()["locked"], true); assert!(s.guides_locked()); assert!(matches!(s.execute("guide.remove", &json!({"index": 0})), Err(EngineError::Disabled(..)))); s.execute("view.guides.lock", &json!({})).unwrap(); s.execute("guide.remove", &json!({"index": 0})).unwrap(); - assert!(s.doc().unwrap().doc.guides.is_empty()); + assert!(s.doc().unwrap().doc.ruler_guides().is_empty()); assert!(s.execute("guide.remove", &json!({"index": 0})).is_err()); } @@ -3804,7 +3804,7 @@ fn invalid_guide_lock_parameters_keep_guides_selected() { for params in [json!({"locked": "false"}), json!({"locked": 1})] { assert!(s.execute("view.guides.lock", ¶ms).is_err(), "accepted {params}"); assert!(!s.guides_locked(), "locked guides for {params}"); - assert_eq!(s.doc().unwrap().selection.guides, vec![0], "cleared selection for {params}"); + assert_eq!(selected_guides(&s), vec![0], "cleared selection for {params}"); assert_eq!(s.doc().unwrap().history.undo.len(), before_undo, "recorded undo for {params}"); } // Left out, or null as for every command, it toggles. @@ -3987,11 +3987,11 @@ fn a_failed_preview_leaves_locked_guides_deselected() { } fn guides(s: &Session) -> Vec<(bool, f64)> { - s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect() } fn selected_guides(s: &Session) -> Vec { - s.doc().unwrap().selection.guides.clone() + super::selected_guides(s) } /// #414: ruler guides are selected, moved, copied, nudged and deleted like art, over commands. @@ -4219,10 +4219,12 @@ fn guides_and_art_later_cases(s: &mut Session, a: NodeId) { assert_eq!(guides(s).len(), 4); assert_eq!(guides(s)[2], (true, 210.0)); assert_eq!(guides(s)[3], (false, 320.0)); - assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); + // The layer's art (its ruler guides are listed after it). + let art = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().filter(|c| !c.is_ruler_guide()).count(); + assert_eq!(art(s), 2); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(guides(s).len(), 2); - assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + assert_eq!(art(s), 1); } /// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved, @@ -4244,7 +4246,7 @@ fn artboard_guides_go_with_their_artboard() { ); let spans = |s: &Session| { let d = &s.doc().unwrap().doc; - d.guides.iter().map(|g| d.guide_span(g)).collect::>() + d.ruler_guides().iter().map(|g| d.guide_span(g)).collect::>() }; assert_eq!(spans(&s), [Some((0.0, 300.0)), None]); for bad in [json!(5), json!("x"), json!(-1)] { @@ -4270,7 +4272,7 @@ fn artboard_guides_go_with_their_artboard() { // Rearranged, they follow their artboards. s.execute("artboard.rearrange", &json!({"columns": 4})).unwrap(); let d = &s.doc().unwrap().doc; - for (g, ab) in d.guides.iter().filter_map(|g| Some((g, d.artboards.iter().find(|a| Some(a.id) == g.artboard)?))) { + for (g, ab) in d.ruler_guides().iter().filter_map(|g| Some((g, d.artboards.iter().find(|a| Some(a.id) == g.artboard)?))) { assert_eq!(g.pos - ab.rect.x0, 160.5, "{g:?} on {ab:?}"); } // Deleted, the artboard takes its guides; the selected ones left stay selected. @@ -4405,13 +4407,16 @@ fn cmd_ruler_drags_make_guides_of_the_active_artboard_when_the_preference_is_on( #[test] fn guides_from_older_files_are_put_on_a_layer() { let mut s = Session::new(); - let mut d = vectorcraft_doc::Document::new(400.0, 300.0); - d.guides.push(vectorcraft_doc::Guide::new(true, 50.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + // A document read with its guides on no layer, as such a file holds them. + let mut v = serde_json::to_value(vectorcraft_doc::Document::new(400.0, 300.0)).unwrap(); + v["guides"] = json!([{"vertical": true, "pos": 50.0}, {"vertical": false, "pos": 80.0}]); + let d: vectorcraft_doc::Document = serde_json::from_value(v).unwrap(); s.add_document(d, None); let st = s.doc().unwrap(); let top = st.doc.default_layer(); - assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides); + let guides = st.doc.ruler_guides(); + assert_eq!(guides.len(), 2); + assert!(top.is_some() && guides.iter().all(|g| g.layer == top), "{guides:?}"); } /// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it, @@ -4421,10 +4426,10 @@ fn ruler_guides_live_on_layers() { let mut s = session(); let first = s.doc().unwrap().current_layer().unwrap(); s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(first), "on the current layer"); let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap()); s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[1].layer, Some(second), "the new layer is current"); assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err()); // Hidden or locked with its layer. s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap(); @@ -4435,12 +4440,12 @@ fn ruler_guides_live_on_layers() { s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap(); // Moved to the first layer, then back. s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first)); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[1].layer, Some(first)); s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap(); assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err()); // Several at once, as dragging their rows onto a layer's row does. s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap(); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.layer == Some(first))); + assert!(s.doc().unwrap().doc.ruler_guides().iter().all(|g| g.layer == Some(first))); s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap(); assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err()); // Deleting a layer deletes its guides; undo brings them back. @@ -4476,7 +4481,7 @@ fn guides_on_hidden_or_locked_layers_are_left_alone() { s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap(); // The current layer locked: the new guide goes on the top shown, unlocked layer. s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first)); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[2].layer, Some(first)); assert_eq!(guides(&s).len(), 3); } @@ -4489,7 +4494,7 @@ fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() { s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); let second = id_of(&s.execute("layer.new", &json!({})).unwrap()); s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); - let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::>(); + let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).iter().map(|g| (g.vertical, g.pos)).collect::>(); // Duplicate Layer copies its guides onto the copy. let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap(); assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)])); diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 4d549c31ef..7e7204f6e1 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -798,7 +798,7 @@ fn copied_artboards_take_a_free_id_and_keep_their_own_guides() { let mut kept = vec![]; for i in [1, 0] { s.execute("artboard.delete", &json!({"index": i})).unwrap(); - kept.push(s.doc().unwrap().doc.guides.iter().map(|g| g.artboard).collect::>()); + kept.push(s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.artboard).collect::>()); s.execute("edit.undo", &json!({})).unwrap(); } got.push((cmd, s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect::>(), kept)); @@ -1675,12 +1675,12 @@ fn delete_empty_artboards_deletes_those_with_no_art_in_one_step() { assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]})); let st = s.doc().unwrap(); assert_eq!(artboard_names(&s), ["Artboard 2"]); - assert_eq!(st.doc.guides.len(), 1, "the deleted artboards' guides go with them"); + assert_eq!(st.doc.ruler_guides().len(), 1, "the deleted artboards' guides go with them"); assert_eq!(st.doc.node(art).map(|n| n.geometric_bounds()), before.node(art).map(|n| n.geometric_bounds()), "the art stays"); assert_eq!(st.history.undo.len(), steps + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3"]); - assert_eq!(s.doc().unwrap().doc.guides.len(), 3); + assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 3); } /// What counts as art: hidden and locked objects, an object straddling two artboards and one diff --git a/crates/engine/src/tests/pathops.rs b/crates/engine/src/tests/pathops.rs index 72aa559ae0..1397357061 100644 --- a/crates/engine/src/tests/pathops.rs +++ b/crates/engine/src/tests/pathops.rs @@ -656,13 +656,13 @@ fn simplify_refreshes_changed_path_selection_and_undo() { let kept = NodeId(s.execute("path.create", &json!({"anchors": [{"x": 350, "y": 180}]})).unwrap()["id"].as_u64().unwrap()); let unrelated = rect(&mut s, 400.0, 200.0, 40.0, 30.0); s.edit("selection fixture", |d, sel| { - d.guides.push(vectorcraft_doc::Guide::new(true, 30.0)); + let guide = d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 30.0)); sel.set([kept, id]); sel.anchors.insert(id, [(0, 19)].into_iter().collect()); sel.anchors.insert(kept, [(0, 0)].into_iter().collect()); sel.key = Some(kept); sel.target = Some(id); - sel.guides.push(0); + sel.guides.push(guide); Ok(()) }) .unwrap(); @@ -781,7 +781,7 @@ fn split_into_grid_options() { rect(&mut s, 10.0, 20.0, 110.0, 50.0); let r = s.execute("object.path.splitIntoGrid", &p).unwrap(); let cells: Vec = ids(&r).iter().map(|id| bounds(&s, *id)).collect(); - let guides: Vec<(bool, f64)> = s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect(); + let guides: Vec<(bool, f64)> = s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect(); assert_eq!(r["guides"], guides.len()); (cells, guides) }; @@ -812,13 +812,16 @@ fn split_into_grid_options() { assert!(close(bounds(&s, r[0]), Rect::new(0.0, 0.0, 40.0, 60.0), 1e-9) && close(bounds(&s, r[1]), Rect::new(60.0, 0.0, 100.0, 60.0), 1e-9)); let st = s.doc().unwrap(); assert!(r.iter().all(|id| node(&s, *id).appearance == node(&s, r[0]).appearance) && st.doc.node(back).is_none() && st.doc.node(front).is_none()); - assert_eq!(st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::>(), [kept, r[0], r[1]], "above the path left alone"); + let art: Vec = st.doc.layers[0].children().unwrap().iter().filter(|n| !n.is_ruler_guide()).map(|n| n.id).collect(); + assert_eq!(art, [kept, r[0], r[1]], "above the path left alone"); + let children = st.doc.layers[0].children().unwrap(); + assert!(children.len() == 9 && children[3..].iter().all(|n| n.is_ruler_guide()), "the six guides are above the art"); assert_eq!(st.selection.objects, r); - assert!(st.doc.guides.iter().all(|g| g.layer == Some(st.doc.layers[0].id))); + assert!(st.doc.ruler_guides().iter().all(|g| g.layer == Some(st.doc.layers[0].id))); assert_eq!(st.history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); let st = s.doc().unwrap(); - assert!(st.doc.node(back).is_some() && st.doc.node(front).is_some() && st.doc.guides.is_empty()); + assert!(st.doc.node(back).is_some() && st.doc.node(front).is_some() && st.doc.ruler_guides().is_empty()); // A grid with no room for its gutters is refused and changes nothing. assert!(s.execute("object.path.splitIntoGrid", &json!({"columns": 4, "gutter": 50})).is_err()); assert!(s.execute("object.path.splitIntoGrid", &json!({"width": -5})).is_err()); diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 8e3c043e4e..ed37d09695 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -1990,9 +1990,12 @@ fn real_time_drawing_off_still_shows_a_dragged_guide() { set_pref(&mut s, "realTimeDrawing", json!(false)); s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 200.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 200.0), v).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 150.0, "the guide moves while dragged"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].pos, 150.0, "the guide moves while dragged"); s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 200.0), v).unwrap(); - assert_eq!((s.doc().unwrap().doc.guides[0].pos, s.doc().unwrap().history.undo.last().map(|e| e.label.as_str())), (150.0, Some("Move Guide"))); + assert_eq!( + (s.doc().unwrap().doc.ruler_guides()[0].pos, s.doc().unwrap().history.undo.last().map(|e| e.label.as_str())), + (150.0, Some("Move Guide")) + ); } /// With Real-time Drawing off, a 9-slice guide of the edited symbol moved with the Selection tool diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 68b8b57abc..d92365a356 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -579,7 +579,7 @@ fn units_and_ruler_guides_survive_save_and_reopen() { s.execute("file.close", &json!({})).unwrap(); s.execute("document.open", &json!({"path": p})).unwrap(); let doc = &s.doc().unwrap().doc; - let guides: Vec<_> = doc.guides.iter().map(|g| (g.vertical, g.pos)).collect(); + let guides: Vec<_> = doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect(); assert_eq!((doc.units, guides), (vectorcraft_doc::Unit::Millimeters, vec![(true, 50.0), (false, 30.0)]), "{ext}"); } let _ = std::fs::remove_dir_all(d); diff --git a/crates/engine/src/tests/slices.rs b/crates/engine/src/tests/slices.rs index 87d0088d97..79b2cbe662 100644 --- a/crates/engine/src/tests/slices.rs +++ b/crates/engine/src/tests/slices.rs @@ -93,7 +93,7 @@ fn too_many_guide_pieces_are_refused() { let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); for i in 1..3000 { for vertical in [true, false] { - doc.guides.push(vectorcraft_doc::Guide::new(vertical, f64::from(i) * 0.1)); + doc.add_ruler_guide(&vectorcraft_doc::Guide::new(vertical, f64::from(i) * 0.1)); } } assert!(s.execute("object.slice.fromGuides", &json!({})).unwrap_err().to_string().contains("more than")); @@ -440,9 +440,9 @@ fn a_canceled_undo_scope_leaves_locked_or_hidden_slices_deselected() { /// Select slice `id` and the first ruler guide together, through [`Session::select`]. fn pick_slice_and_guide(s: &mut Session, id: u64) { - s.select(|_, sel| { + s.select(|d, sel| { sel.slices = vec![NodeId(id)]; - sel.guides = vec![0]; + sel.guides = d.ruler_guides().first().and_then(|g| g.id).into_iter().collect(); }) .unwrap(); } diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index f49a88c80b..47dcaf8318 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1016,7 +1016,7 @@ fn dropped_type_press_restores_the_original_text_and_selection() { let a = at(&s, first); click(&mut s, a.x + 0.1, a.y); assert!(s.tool_wants_text()); - s.select(|_, selection| selection.guides.push(0)).unwrap(); + s.select(|d, selection| selection.guides.extend(d.ruler_guides().first().and_then(|g| g.id))).unwrap(); let before = s.doc().unwrap().selection.clone(); let b = at(&s, second); s.pointer(&PointerEvent::new(PointerKind::Down, b.x + 0.1, b.y), v).unwrap(); diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index b096922e9a..e9aa3c5329 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -766,7 +766,7 @@ fn stroke_width(s: &Session, id: NodeId) -> f64 { } fn guide_positions(s: &Session) -> Vec { - s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect() } /// Scale Artwork with Artboard (#602): `artboard.setProps {scaleArt}` takes the art fully inside the diff --git a/crates/engine/tests/it/beyond_canvas.rs b/crates/engine/tests/it/beyond_canvas.rs index 9dee868d54..6ac31398f6 100644 --- a/crates/engine/tests/it/beyond_canvas.rs +++ b/crates/engine/tests/it/beyond_canvas.rs @@ -51,7 +51,7 @@ fn an_artboard_a_guide_or_a_ruler_origin_beyond_the_canvas_no_longer_blocks_edit v["document"]["ruler_origin"] = json!([1e17, 0.0]); let (s, warnings) = open_and_move(&v, "setup"); let d = &s.doc().unwrap().doc; - assert_eq!((d.artboards.len(), d.guides.len(), d.ruler_origin.x), (1, 1, 0.0)); + assert_eq!((d.artboards.len(), d.ruler_guides().len(), d.ruler_origin.x), (1, 1, 0.0)); assert!(warnings.to_string().contains("1 artboard, 1 guide beyond the canvas"), "{warnings}"); assert!(warnings.to_string().contains("ruler origin"), "{warnings}"); } diff --git a/crates/engine/tests/it/clear_commands.rs b/crates/engine/tests/it/clear_commands.rs index 655c719965..a50c70c18d 100644 --- a/crates/engine/tests/it/clear_commands.rs +++ b/crates/engine/tests/it/clear_commands.rs @@ -177,12 +177,12 @@ fn clear_guides_and_empty_explicit_targets_keep_their_separate_behavior() { s.execute("edit.clear", &json!({"ids": []})).unwrap(); assert_eq!(s.active().unwrap().history.undo.len(), history); assert_eq!(s.active().unwrap().selection, selection); - assert_eq!(s.active().unwrap().doc.guides.len(), 1); + assert_eq!(s.active().unwrap().doc.ruler_guides().len(), 1); s.execute("edit.clear", &json!({})).unwrap(); - assert!(s.active().unwrap().doc.guides.is_empty()); + assert!(s.active().unwrap().doc.ruler_guides().is_empty()); assert!(s.active().unwrap().doc.node(a).is_some()); s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc.guides.len(), 1); + assert_eq!(s.active().unwrap().doc.ruler_guides().len(), 1); } #[test] diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 7f85e5acd3..9227ee199d 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -27,6 +27,8 @@ mod param_keys; mod paste_many; mod pen_click_after_smooth; mod pen_endpoint_picking; +mod ruler_guide_nodes; +mod ruler_guide_perf; mod ruler_origin; mod selection_commands; mod stroke_visibility; diff --git a/crates/engine/tests/it/ruler_guide_nodes.rs b/crates/engine/tests/it/ruler_guide_nodes.rs new file mode 100644 index 0000000000..95625d65d1 --- /dev/null +++ b/crates/engine/tests/it/ruler_guide_nodes.rs @@ -0,0 +1,439 @@ +//! Ruler guides held as nodes of a layer: commands that work on a layer's art leave them out and +//! keep them above the art. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_doc::{Node, NodeId, NodeKind}; +use vectorcraft_engine::Session; + +fn id(v: &serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + id(&s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()) +} + +/// A ruler guide added on top of `layer`'s contents. +fn guide(s: &mut Session, layer: NodeId, pos: f64) -> NodeId { + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos, artboard: None }); + d.insert(Some(layer), usize::MAX, g).unwrap() +} + +fn children(s: &Session, layer: NodeId) -> Vec { + s.doc().unwrap().doc.children(Some(layer)).unwrap().iter().map(|c| c.id).collect() +} + +/// A document with a layer holding two rectangles and two ruler guides: (layer, [art], [guides]). +fn session() -> (Session, NodeId, Vec, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let art = vec![rect(&mut s, 10.0), rect(&mut s, 50.0)]; + let guides = vec![guide(&mut s, layer, 5.0), guide(&mut s, layer, 7.0)]; + (s, layer, art, guides) +} + +#[test] +fn layer_commands_keep_ruler_guides_above_the_art() { + // Send to Current Layer puts the object below the current layer's guides. + let (mut s, a, art, guides) = session(); + let b = id(&s.execute("layer.new", &json!({})).unwrap()); + let moved = rect(&mut s, 90.0); + s.execute("layer.setCurrent", &json!({"id": a.0})).unwrap(); + s.execute("select.set", &json!({"ids": [moved.0]})).unwrap(); + s.execute("object.arrange.sendToCurrentLayer", &json!({})).unwrap(); + assert_eq!(children(&s, a), [art[0], art[1], moved, guides[0], guides[1]]); + // Already on top of the art: nothing changes and no undo step is added. + let steps = s.doc().unwrap().history.undo.len(); + s.execute("object.arrange.sendToCurrentLayer", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + + // Moving a row on top of a layer's contents puts it below the guides. + let other = rect(&mut s, 130.0); + s.execute("layer.move", &json!({"id": other.0, "target": a.0})).unwrap(); + assert_eq!(children(&s, a), [art[0], art[1], moved, other, guides[0], guides[1]]); + + // Merging layers keeps every guide above the merged art. + let gb = guide(&mut s, b, 9.0); + let late = { + s.execute("layer.setCurrent", &json!({"id": b.0})).unwrap(); + rect(&mut s, 170.0) + }; + s.execute("layer.merge", &json!({"ids": [a.0, b.0]})).unwrap(); + let merged = children(&s, b); + let first_guide = merged.iter().position(|c| [guides[0], guides[1], gb].contains(c)).unwrap(); + assert_eq!(merged.len() - first_guide, 3, "{merged:?}"); + assert!(merged.contains(&late)); +} + +#[test] +fn release_and_clipping_leave_ruler_guides_alone() { + let (mut s, layer, art, guides) = session(); + // Make Clipping Mask clips by the top object, not by a guide above it. + assert_eq!(s.execute("layer.clippingMask.toggle", &json!({"id": layer.0})).unwrap()["clip"], json!(true)); + assert_eq!(children(&s, layer), [art[1], art[0], guides[0], guides[1]]); + s.execute("edit.undo", &json!({})).unwrap(); + + // Release to Layers makes layers of the art only, below the guides. + s.execute("layer.releaseToLayers", &json!({"id": layer.0})).unwrap(); + let ch = children(&s, layer); + assert_eq!(ch.len(), 4); + assert_eq!(ch[2..], [guides[0], guides[1]]); + let d = &s.doc().unwrap().doc; + assert!(ch[..2].iter().all(|c| d.node(*c).is_some_and(Node::is_layer))); +} + +#[test] +fn ruler_guides_are_not_art_to_select_paint_or_inspect() { + let (mut s, layer, art, guides) = session(); + // Target Contents selects the layer's objects only. + let out = s.execute("appearance.targetContents", &json!({"ids": [layer.0]})).unwrap(); + assert_eq!(out["ids"], json!([art[0].0, art[1].0])); + // Painting the layer paints its objects and leaves the guides with no appearance. + s.execute("paint.setFill", &json!({"ids": [layer.0], "color": "#ff0000"})).unwrap(); + let d = &s.doc().unwrap().doc; + for g in &guides { + assert_eq!(d.node(*g).unwrap().appearance, vectorcraft_doc::Appearance::default()); + } + // Document inspection lists the objects, not the guides. + assert_eq!(s.execute("document.find", &json!({"kind": "Guide"})).unwrap()["total"], json!(0)); + let layer_row = s.execute("document.inspect", &json!({"ids": [layer.0]})).unwrap(); + let listed = layer_row.to_string(); + assert!(guides.iter().all(|g| !listed.contains(&format!("\"id\":{}", g.0))), "{listed}"); + // Unlock All does not reach a locked guide. + Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(guides[0]).unwrap().locked = true; + s.execute("object.unlockAll", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(guides[0]).unwrap().locked); +} + +#[test] +fn flattening_a_sublayers_art_keeps_its_ruler_guides() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + let r = rect(&mut s, 10.0); + s.execute("transparency.set", &json!({"ids": [r.0], "opacity": 50})).unwrap(); + let g = guide(&mut s, sub, 5.0); + s.execute("object.flattenTransparency", &json!({"ids": [sub.0]})).unwrap(); + // The sublayer stands for its art: the art is flattened and the guide stays on top of it. + let d = &s.doc().unwrap().doc; + assert!(d.node(r).is_none(), "the art was flattened"); + assert!(d.node(g).is_some_and(Node::is_ruler_guide), "the guide is kept"); + assert_eq!(d.parent_of(g), Some(sub)); + assert_eq!(children(&s, sub).len(), 2); + assert_eq!(children(&s, sub).last(), Some(&g)); + assert_eq!(d.parent_of(sub), Some(layer)); +} + +#[test] +fn object_commands_refuse_a_ruler_guide_id() { + let (mut s, _, art, guides) = session(); + s.execute("select.set", &json!({"ids": [art[0].0]})).unwrap(); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), s.doc().unwrap().history.undo.len()); + for (command, params) in [ + ("select.set", json!({"ids": [guides[0].0]})), + ("select.set", json!({"ids": [art[1].0, guides[1].0]})), + ("edit.clear", json!({"ids": [guides[0].0]})), + ("object.lock", json!({"ids": [guides[0].0]})), + ("object.move", json!({"ids": [guides[0].0], "dx": 5, "dy": 0})), + ("paint.setFill", json!({"ids": [guides[1].0], "color": "#ff0000"})), + ] { + let e = s.execute(command, ¶ms).unwrap_err().to_string(); + assert!(e.contains("is a ruler guide, not an object"), "{command}: {e}"); + let d = s.doc().unwrap(); + assert_eq!((&d.doc, &d.selection, d.history.undo.len()), (&before.0, &before.1, before.2), "{command} changed something"); + } + // A selection holding a guide id as an object loses it when pruned; the guide stays. + let d = &s.doc().unwrap().doc; + let mut sel = vectorcraft_doc::Selection { objects: vec![art[0], guides[0]], key: Some(guides[1]), ..Default::default() }; + sel.prune(d); + assert_eq!((sel.objects, sel.key), (vec![art[0]], None)); + assert!(d.node(guides[0]).is_some_and(Node::is_ruler_guide)); +} + +#[test] +fn a_ruler_guide_stays_directly_in_a_layer() { + let (mut s, layer, art, guides) = session(); + s.execute("select.set", &json!({"ids": [art[0].0, art[1].0]})).unwrap(); + let group = id(&s.execute("object.group", &json!({})).unwrap()); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + // Reorder refuses a group as a guide's parent, and the top level. + for parent in [json!(group.0), json!(null)] { + assert!(s.execute("node.move", &json!({"id": guides[0].0, "parent": parent, "index": 0})).is_err(), "{parent}"); + assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&before.0, before.1)); + } + // So do the document's own insert and move. + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + assert!(d.move_node(guides[1], Some(group), 0).is_err()); + let loose = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None }); + assert!(d.insert(Some(group), 0, loose.clone()).is_err()); + assert!(d.insert(None, 0, loose.clone()).is_err()); + assert_eq!(d.ruler_guides().iter().filter_map(|g| g.id).collect::>(), guides); + assert_eq!(children(&s, layer), [group, guides[0], guides[1]]); + // Within its layer it still moves among the guides. + s.execute("node.move", &json!({"id": guides[1].0, "parent": layer.0, "index": 0})).unwrap(); + assert_eq!(children(&s, layer), [group, guides[1], guides[0]]); +} + +/// Arrange counts only a layer's art: the guides above it are no place to move to. +#[test] +fn arranging_counts_only_a_layers_art() { + let (mut s, layer, art, guides) = session(); + let top = rect(&mut s, 90.0); + assert_eq!(children(&s, layer), [art[0], art[1], top, guides[0], guides[1]]); + s.execute("select.set", &json!({"ids": [art[0].0, top.0]})).unwrap(); + s.execute("object.arrange.bringToFront", &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[1], art[0], top, guides[0], guides[1]], "the selection keeps its order at the top of the art"); + // The topmost art brought to the front or forward: nothing changes and no undo step is added. + s.execute("select.set", &json!({"ids": [top.0]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + for command in ["object.arrange.bringToFront", "object.arrange.bringForward"] { + s.execute(command, &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[1], art[0], top, guides[0], guides[1]], "{command}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{command}"); + } + s.execute("select.set", &json!({"ids": [art[1].0]})).unwrap(); + s.execute("object.arrange.bringForward", &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[0], art[1], top, guides[0], guides[1]]); +} + +/// A guide made while an opacity mask, a pattern or a symbol is edited goes on the temporary +/// editing layer, and stays in the document when editing ends; a save made during mask editing +/// keeps it. +#[test] +fn guides_made_while_editing_a_mask_a_pattern_or_a_symbol_stay() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let edit_layer = s.doc().unwrap().doc.mask_edit.expect("editing the mask").layer; + s.execute("guide.add", &json!({"vertical": true, "pos": 40})).unwrap(); + let made = s.doc().unwrap().doc.ruler_guides(); + assert_eq!(made.iter().map(|g| (g.layer, g.pos)).collect::>(), [(Some(edit_layer), 40.0)]); + let saved = s.doc().unwrap().doc.without_edit_modes().ruler_guides(); + assert_eq!(saved.iter().map(|g| (g.id, g.pos)).collect::>(), [(made[0].id, 40.0)], "a save keeps the guide"); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| (g.id, g.pos)).collect::>(), [(made[0].id, 40.0)]); + assert!(kept[0].layer.is_some_and(|l| l != edit_layer && d.node(l).is_some_and(Node::is_layer))); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + s.execute("object.pattern.make", &json!({})).unwrap(); + let tile_layer = s.doc().unwrap().doc.pattern_edit.as_ref().expect("editing the pattern").layer; + s.execute("guide.add", &json!({"vertical": false, "pos": 25})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(tile_layer)); + s.execute("object.pattern.done", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| g.pos).collect::>(), [25.0]); + assert!(kept[0].layer.is_some_and(|l| l != tile_layer && d.node(l).is_some_and(Node::is_layer))); + let tile = &d.patterns.last().expect("the new pattern").art; + assert!(tile.iter().all(|n| !n.is_ruler_guide()), "the guide is not part of the tile"); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + let instance = id(&s.execute("symbol.new", &json!({"name": "Mark"})).unwrap()); + s.execute("symbol.edit", &json!({"ids": [instance.0]})).unwrap(); + let edit_layer = s.doc().unwrap().doc.symbol_edit.as_ref().expect("editing the symbol").layer; + s.execute("guide.add", &json!({"vertical": true, "pos": 15})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(edit_layer)); + s.execute("symbol.exitEditing", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| g.pos).collect::>(), [15.0]); + assert!(kept[0].layer.is_some_and(|l| l != edit_layer && d.node(l).is_some_and(Node::is_layer))); + let art = &d.symbols.iter().find(|x| x.name == "Mark").expect("the symbol").art; + let mut in_symbol = false; + art.walk(&mut |n| in_symbol |= n.is_ruler_guide()); + assert!(!in_symbol, "the guide is not part of the symbol"); +} + +/// The bytes of each export format (PDF without the embedded native document, which keeps the +/// guides), and the box Fit All in Window fits (the art and every artboard). +fn exports(s: &mut Session) -> (Vec<(String, String)>, Option) { + let files = [json!({"format": "svg"}), json!({"format": "pdf", "preserveEditing": false}), json!({"format": "png"}), json!({"format": "psd"})] + .iter() + .map(|p| { + let out = s.execute("document.export", p).unwrap(); + (p["format"].as_str().unwrap().to_string(), out["dataBase64"].as_str().unwrap().to_string()) + }) + .collect(); + let d = &s.doc().unwrap().doc; + let fit = d.art_bounds().map(|a| d.artboards.iter().fold(a, |r, ab| r.union(ab.rect))); + (files, fit) +} + +/// Ruler guides on a layer, on a sublayer and on an artboard change no export and not the box +/// Fit All in Window fits. +#[test] +fn exports_and_fit_all_are_the_same_with_ruler_guides() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 150, "artboards": 2})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + rect(&mut s, 10.0); + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 60, "y": 30, "width": 40, "height": 30})).unwrap(); + let without = exports(&mut s); + assert_eq!(exports(&mut s), without, "the exports are repeatable"); + // Guides far outside the art too: they must not widen any box. + s.execute("guide.add", &json!({"vertical": true, "pos": -5000, "layer": layer.0})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 9000, "layer": sub.0})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 120, "artboard": 1})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 3); + let with = exports(&mut s); + for ((format, a), (_, b)) in without.0.iter().zip(&with.0) { + assert!(a == b, "{format} export changed with ruler guides present"); + } + assert_eq!(with.1, without.1, "Fit All"); + // A PDF that preserves editing carries them in its native document, and opens with them. + let guides = s.doc().unwrap().doc.ruler_guides(); + let pdf = s.execute("document.export", &json!({"format": "pdf"})).unwrap()["dataBase64"].clone(); + s.execute("document.open", &json!({"name": "guides.pdf", "dataBase64": pdf})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides(), guides); +} + +/// One step of [`guide_commands_follow_a_list_model`]. +#[derive(Clone, Debug)] +enum GuideOp { + Add { vertical: bool, pos: u16, layer: usize }, + Remove(usize), + Copy(usize), + Paste, + SetCurrent(usize), + Undo, + Redo, +} + +fn guide_op() -> impl proptest::strategy::Strategy { + use proptest::prelude::*; + prop_oneof![ + 3 => (any::(), 0u16..400, 0usize..3).prop_map(|(vertical, pos, layer)| GuideOp::Add { vertical, pos, layer }), + 2 => (0usize..16).prop_map(GuideOp::Remove), + 1 => (0usize..16).prop_map(GuideOp::Copy), + 1 => Just(GuideOp::Paste), + 1 => (0usize..3).prop_map(GuideOp::SetCurrent), + 2 => Just(GuideOp::Undo), + 1 => Just(GuideOp::Redo), + ] +} + +/// A guide as `guide.list` reports it: (vertical, pos, layer). +type Line = (bool, f64, u64); + +fn listed(s: &mut Session) -> Vec { + let rows = s.execute("guide.list", &json!({})).unwrap(); + rows.as_array() + .unwrap() + .iter() + .enumerate() + .map(|(i, r)| { + assert_eq!(r["index"], json!(i)); + (r["vertical"].as_bool().unwrap(), r["pos"].as_f64().unwrap(), r["layer"].as_u64().unwrap()) + }) + .collect() +} + +/// Runs `ops` on a document with two layers and a sublayer, checking `guide.list` against a plain +/// list after every step: Add appends (a new guide takes the highest id), Remove takes the entry +/// out, Paste appends the copied guide on the current layer at the same place, and Undo and +/// Redo bring back the list as it was, the same indexes included. +fn run_guide_ops(ops: &[GuideOp]) -> Result<(), proptest::test_runner::TestCaseError> { + use proptest::prop_assert_eq; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let bottom = s.doc().unwrap().doc.layers[0].id; + let top = id(&s.execute("layer.new", &json!({})).unwrap()); + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": bottom.0})).unwrap()); + let layers = [bottom, top, sub]; + s.execute("layer.setCurrent", &json!({"id": bottom.0})).unwrap(); + let mut current = bottom; + let mut model: Vec = vec![]; + let mut clip: Option<(bool, f64)> = None; + let (mut undo, mut redo): (Vec>, Vec>) = (vec![], vec![]); + for (step, op) in ops.iter().enumerate() { + let steps = s.doc().unwrap().history.undo.len(); + let before = model.clone(); + // Whether the step is an edit (one undo step). + let edit = match *op { + GuideOp::Add { vertical, pos, layer } => { + let l = layers[layer]; + let r = s.execute("guide.add", &json!({"vertical": vertical, "pos": pos, "layer": l.0})).unwrap(); + model.push((vertical, f64::from(pos), l.0)); + prop_assert_eq!(&r["index"], &json!(model.len() - 1), "step {}", step); + true + } + GuideOp::Remove(i) if !model.is_empty() => { + let i = i % model.len(); + s.execute("guide.remove", &json!({"index": i})).unwrap(); + model.remove(i); + true + } + GuideOp::Copy(i) if !model.is_empty() => { + let i = i % model.len(); + s.execute("guide.select", &json!({"indexes": [i]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + clip = Some((model[i].0, model[i].1)); + false + } + GuideOp::Paste => match clip { + Some((vertical, pos)) => { + s.execute("edit.paste", &json!({})).unwrap(); + model.push((vertical, pos, current.0)); + true + } + None => false, + }, + GuideOp::SetCurrent(l) => { + current = layers[l]; + s.execute("layer.setCurrent", &json!({"id": current.0})).unwrap(); + false + } + GuideOp::Undo => { + if let Some(m) = undo.pop() { + s.execute("edit.undo", &json!({})).unwrap(); + redo.push(std::mem::replace(&mut model, m)); + } + false + } + GuideOp::Redo => { + if let Some(m) = redo.pop() { + s.execute("edit.redo", &json!({})).unwrap(); + undo.push(std::mem::replace(&mut model, m)); + } + false + } + GuideOp::Remove(_) | GuideOp::Copy(_) => false, + }; + let after = s.doc().unwrap().history.undo.len(); + if edit { + prop_assert_eq!(after, steps + 1, "step {} {:?}: one undo step", step, op); + undo.push(before); + redo.clear(); + } else if !matches!(op, GuideOp::Undo | GuideOp::Redo) { + prop_assert_eq!(after, steps, "step {} {:?}: no undo step", step, op); + } + prop_assert_eq!(listed(&mut s), model.clone(), "step {} {:?}", step, op); + } + Ok(()) +} + +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config { cases: 64, failure_persistence: None, ..proptest::test_runner::Config::default() })] + + /// Add, remove, copy, paste, undo and redo keep `guide.list` in step with a plain list, on + /// three layers. + #[test] + fn guide_commands_follow_a_list_model(ops in proptest::collection::vec(guide_op(), 1..40)) { + run_guide_ops(&ops)?; + } +} diff --git a/crates/engine/tests/it/ruler_guide_perf.rs b/crates/engine/tests/it/ruler_guide_perf.rs new file mode 100644 index 0000000000..1b5de5c5e2 --- /dev/null +++ b/crates/engine/tests/it/ruler_guide_perf.rs @@ -0,0 +1,130 @@ +//! Timings of a document with 10,000 ruler guides and 100,000 objects: save, load, open, the guide +//! list, hovering and dragging with the Selection tool, an edit that does not touch guides and one +//! that does. Ignored by default; it prints its timings and sets no bound on them: +//! `cargo test -p vectorcraft-engine --test it ruler_guide_perf -- --ignored --nocapture`. + +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::Document; +use vectorcraft_engine::{Session, ViewInfo}; +use vectorcraft_geom::Rect; +use vectorcraft_testkit::fixtures::DocBuilder; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +const GUIDES: usize = 10_000; +const LAYERS: usize = 4; +const GROUPS_PER_LAYER: usize = 250; +const RECTS_PER_GROUP: usize = 100; + +/// The `.vectorcraft` file of the document: 100,000 rectangles in groups of 100 on four layers, +/// and the ruler guides in its saved list, spread over the layers. +fn file() -> Vec { + let mut b = DocBuilder::new(2000.0, 2000.0); + let mut layers = vec![b.layer]; + for l in 0..LAYERS { + if l > 0 { + layers.push(b.layer(&format!("Layer {}", l + 1))); + } + for g in 0..GROUPS_PER_LAYER { + let kids = (0..RECTS_PER_GROUP) + .map(|r| { + let (x, y) = (((g * RECTS_PER_GROUP + r) % 400) as f64 * 5.0, (l * GROUPS_PER_LAYER + g) as f64 * 2.0); + b.rect_node(Rect::new(x, y, x + 3.0, y + 1.5), Color::rgb(0.2, 0.4, 0.6)) + }) + .collect(); + b.group(kids, false); + } + } + let d: Document = b.build(); + let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&d, false)).unwrap(); + v["document"]["guides"] = + (0..GUIDES).map(|i| json!({"vertical": i % 2 == 0, "pos": (i / 2) as f64 * 0.37 + 0.1, "layer": layers[i % LAYERS]})).collect(); + serde_json::to_vec(&v).unwrap() +} + +/// The median of `n` runs of `f`. +fn median(n: usize, mut f: impl FnMut()) -> Duration { + let mut t: Vec = (0..n) + .map(|_| { + let start = Instant::now(); + f(); + start.elapsed() + }) + .collect(); + t.sort(); + t[n / 2] +} + +fn report(what: &str, d: Duration) { + eprintln!("ruler_guide_perf: {what:<44} {:>10.3} ms", d.as_secs_f64() * 1000.0); +} + +fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); +} + +#[test] +#[ignore = "a benchmark: run with --ignored --nocapture"] +fn ruler_guide_perf() { + let bytes = file(); + eprintln!("ruler_guide_perf: file of {} bytes", bytes.len()); + report("load (format::load), median of 5", median(5, || drop(vectorcraft_format::load(&bytes).unwrap()))); + let doc = vectorcraft_format::load(&bytes).unwrap(); + report("save (format::save), median of 5", median(5, || drop(vectorcraft_format::save(&doc, false)))); + + let mut s = Session::new(); + let data = vectorcraft_format::base64_encode(&bytes); + let start = Instant::now(); + s.execute("document.open", &json!({"name": "perf.vectorcraft", "dataBase64": data})).unwrap(); + report("document.open", start.elapsed()); + let listed = s.execute("guide.list", &json!({})).unwrap().as_array().map_or(0, Vec::len); + assert_eq!(listed, GUIDES); + report("guide.list, median of 11", median(11, || drop(s.execute("guide.list", &json!({})).unwrap()))); + + s.select_tool("selection", ViewInfo::default()).unwrap(); + let mut i = 0.0; + report( + "hover (Selection tool move), median of 201", + median(201, || { + i += 1.0; + pointer(&mut s, PointerKind::Move, 600.0 + i * 0.13, 300.0 + i * 0.07); + }), + ); + // A drag of the first rectangle's group (the rectangle is at 0,0 to 3,1.5), with its + // snapping: one undo step. + let steps = s.doc().unwrap().history.undo.len(); + pointer(&mut s, PointerKind::Down, 1.5, 0.75); + let mut j = 0.0; + report( + "drag (Selection tool drag step), median of 51", + median(51, || { + j += 1.0; + pointer(&mut s, PointerKind::Drag, 1.5 + j * 0.9, 0.75 + j * 0.4); + }), + ); + let start = Instant::now(); + pointer(&mut s, PointerKind::Up, 1.5 + j * 0.9, 0.75 + j * 0.4); + report("drag release (commit)", start.elapsed()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "the drag moved the group"); + + let mut k = 0.0; + report( + "edit without guides (shape.rectangle), median of 21", + median(21, || { + k += 1.0; + s.execute("shape.rectangle", &json!({"x": k, "y": 1900, "width": 5, "height": 5})).unwrap(); + }), + ); + report("undo, median of 11", median(11, || drop(s.execute("edit.undo", &json!({})).unwrap()))); + let mut m = 0.0; + report( + "guide edit (guide.move), median of 21", + median(21, || { + m += 1.0; + s.execute("guide.move", &json!({"index": GUIDES / 2, "pos": 100.0 + m})).unwrap(); + }), + ); + report("file.saveCopy, median of 3", median(3, || drop(s.execute("file.saveCopy", &json!({})).unwrap()))); +} diff --git a/crates/engine/tests/it/ruler_origin.rs b/crates/engine/tests/it/ruler_origin.rs index 4b5c06286d..f6a910515e 100644 --- a/crates/engine/tests/it/ruler_origin.rs +++ b/crates/engine/tests/it/ruler_origin.rs @@ -78,7 +78,7 @@ fn native_versions_and_compression_keep_the_origin_and_old_files_default_to_zero assert_eq!(back.ruler_origin_for(0), doc.ruler_origin_for(0), "v{version}, compression {compress}"); assert_eq!((back.rulers, &back.artboard_ruler_origins), (doc.rulers, &doc.artboard_ruler_origins), "v{version}, compression {compress}"); assert_eq!(back.layers, doc.layers); - assert_eq!(back.guides, doc.guides); + assert_eq!(back.ruler_guides(), doc.ruler_guides()); } } diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index c598e2cd9a..d77d8264ea 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -460,6 +460,9 @@ impl<'a> Writer<'a> { let flag = |b: bool| u8::from(b); let dim = dim_images.unwrap_or(50); let name = n.name.as_deref().unwrap_or("Layer"); + // Ruler guides are kept in the document's native copy only. + let children: Vec> = children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(); + let children = &children; // The last flag makes the layer a clipping layer: its top object clips the rest. Only a // clipping path or compound path, outside any art cut to a clipping object, is written as // one ([`Self::clip_group`] writes the others). @@ -583,6 +586,10 @@ impl<'a> Writer<'a> { if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } + // Ruler guides are kept in the document's native copy only: no object, state or style. + if n.is_ruler_guide() { + return; + } if self.in_symbol { self.wrote_fixed |= symbols::redrawn(n); } @@ -697,6 +704,8 @@ impl<'a> Writer<'a> { } self.end_group("U", xy); } + // Ruler guides are kept in the document's native copy only. + NodeKind::RulerGuide { .. } => {} } if self.body.len() > start { // A group's dictionary goes between its end and its state block. diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 65fe918599..c4ae9fda3b 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -28,6 +28,10 @@ pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are /// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { fn count(n: &Node) -> usize { + // Ruler guides are not written as objects. + if n.is_ruler_guide() { + return 0; + } let own = usize::from(n.extras().graphic_style.is_some()); let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art)); own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum()) diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index eb3d73c691..ad7f266036 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1244,9 +1244,9 @@ impl<'a> Reader<'a> { None => None, }; let pos = if vertical { p.x } else { p.y }; - pos.is_finite().then_some(Guide { vertical, pos, artboard, layer: None }) + pos.is_finite().then_some(Guide { artboard, ..Guide::new(vertical, pos) }) }; - take_ruler_guides(&mut doc.layers, &pick, &mut doc.guides, 0); + take_ruler_guides(&mut doc.layers, &pick, 0); } let mut brushes = self.brush_library(); if let Some((lib, _)) = &brushes { @@ -1429,9 +1429,9 @@ fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect]) } } -/// Moves the children of the layers and sublayers of `nodes` that `pick` makes a guide of into -/// `guides`, each on its layer. -fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option, guides: &mut Vec, depth: usize) { +/// Makes the children of the layers and sublayers of `nodes` that `pick` makes a guide of into +/// ruler guide nodes with the same ids, moved to the top of their layer in their order. +fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option, depth: usize) { if depth > MAX_DEPTH { return; } @@ -1439,16 +1439,17 @@ fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option { - guides.push(Guide { layer: Some(id), ..g }); + guides.push(Arc::new(Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }))); false } None => true, }); - take_ruler_guides(kids, pick, guides, depth + 1); + take_ruler_guides(kids, pick, depth + 1); + kids.extend(guides); } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c04d06c6ab..46386d2200 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -62,9 +62,14 @@ fn arrays_nested_past_the_depth_limit_are_refused() { assert!(nested(100_000)); } -/// The children of the first layer. +/// The children of the first layer, its ruler guides left out. fn art(doc: &Document) -> Vec> { - doc.layers.first().and_then(|l| l.children()).cloned().unwrap_or_default() + doc.layers.first().and_then(|l| l.children()).into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect() +} + +/// The ruler guides of `doc` by their line, artboard and layer. +fn guide_lines(doc: &Document) -> Vec { + doc.ruler_guides().into_iter().map(|g| Guide { artboard: g.artboard, layer: g.layer, ..Guide::new(g.vertical, g.pos) }).collect() } fn fill(n: &Node) -> Paint { @@ -8459,17 +8464,16 @@ fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() { ); let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; let top = &doc.layers[0]; - let kids = top.children().unwrap(); + let kids = art(&doc); assert_eq!(kids.len(), 2, "the square and the sublayer"); assert!(matches!(kids[0].kind, NodeKind::Path { guide: false, .. })); let sub = &kids[1]; - assert!(sub.children().unwrap().is_empty()); - let want = [ - Guide { vertical: false, pos: 60.0, artboard: None, layer: Some(top.id) }, - Guide { vertical: true, pos: 25.0, artboard: None, layer: Some(top.id) }, - Guide { vertical: false, pos: 10.0, artboard: None, layer: Some(sub.id) }, - ]; - assert_eq!(doc.guides, want); + assert!(sub.children().unwrap().iter().all(|c| c.is_ruler_guide())); + let at = |vertical, pos, layer| Guide { layer: Some(layer), ..Guide::new(vertical, pos) }; + let want = [at(false, 60.0, top.id), at(true, 25.0, top.id), at(false, 10.0, sub.id)]; + assert_eq!(guide_lines(&doc), want); + // The guides sit above the layers' art. + assert!(top.children().unwrap()[2..].iter().all(|c| c.is_ruler_guide())); } /// #779: a straight `(N) *` guide path from edge to edge of one artboard is a ruler guide of that @@ -8478,7 +8482,7 @@ fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() { fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() { // The crop marks' artboard, 0 0 200 100. let doc = read(canvas_stream(&layer(&guide_line(0.0, 30.0, 200.0, 30.0, "N"))).as_bytes()).unwrap().doc; - assert_eq!(doc.guides, [Guide { vertical: false, pos: 70.0, artboard: Some(1), layer: Some(doc.layers[0].id) }]); + assert_eq!(guide_lines(&doc), [Guide { artboard: Some(1), layer: Some(doc.layers[0].id), ..Guide::new(false, 70.0) }]); assert!(art(&doc).is_empty()); // The second of two artboards: art space x 400 to 600, y -150 to 0. let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ @@ -8487,7 +8491,7 @@ fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() { %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; let doc = read_ok(&format!("{boards}{}", layer(&guide_line(500.0, 0.0, 500.0, -150.0, "N")))).doc; assert_eq!(doc.artboards[1].id, 2); - assert_eq!(doc.guides, [Guide { vertical: true, pos: 500.0, artboard: Some(2), layer: Some(doc.layers[0].id) }]); + assert_eq!(guide_lines(&doc), [Guide { artboard: Some(2), layer: Some(doc.layers[0].id), ..Guide::new(true, 500.0) }]); } /// Guide paths that aren't ruler guides stay guide paths: `(S) *` ones (guides made from objects), @@ -8506,7 +8510,7 @@ fn other_guide_paths_stay_guide_paths() { ]; for body in cases { let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; - assert!(doc.guides.is_empty(), "{body}"); + assert!(doc.ruler_guides().is_empty(), "{body}"); assert_eq!(art(&doc).len(), 1, "{body}"); } } diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 31cd90d79b..c86cdb9516 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1669,7 +1669,7 @@ fn page_boards(doc: &Document, visible: &Document, first: usize, pages: &[Rect]) /// Drops the guides of artboards `doc` no longer has: an artboard guide goes with its artboard. fn drop_guides_off_boards(doc: &mut Document) { let kept: Vec = doc.artboards.iter().map(|a| a.id).collect(); - doc.guides.retain(|g| g.artboard.is_none_or(|id| kept.contains(&id))); + doc.retain_guides(&mut vectorcraft_doc::Selection::default(), |g| g.artboard.is_none_or(|id| kept.contains(&id))); } /// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their @@ -1682,13 +1682,12 @@ pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { let layer = Arc::make_mut(layer); layer.transform(a, scaling); scale_wrap_offsets(layer, k); + // A transform leaves ruler guides where they are: their positions scale here. + layer.map_ruler_guide_positions(&mut |_, pos| *pos *= k); } for board in &mut doc.artboards { board.rect = a.transform_rect_bbox(board.rect); } - for guide in &mut doc.guides { - guide.pos *= k; - } if styles { for g in doc.graphic_styles.iter_mut() { g.appearance.scale_strokes(k); diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index 5b87a03c7d..34f458f328 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -122,7 +122,7 @@ pub(super) fn comparison_samples(doc: &Document) -> u32 { let (mut images, mut vector) = (false, false); doc.walk(|n| match n.kind { NodeKind::Image(_) => images = true, - NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {} + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } | NodeKind::RulerGuide { .. } => {} _ => vector = true, }); if images && !vector { 4 } else { 1 } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index f9983c6e83..45a7a92c8c 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -77,6 +77,14 @@ fn names(d: &Document) -> Vec { d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect() } +/// The ruler guides of `d` by their line, artboard and layer. +fn guide_lines(d: &Document) -> Vec { + d.ruler_guides() + .into_iter() + .map(|g| vectorcraft_doc::Guide { artboard: g.artboard, layer: g.layer, ..vectorcraft_doc::Guide::new(g.vertical, g.pos) }) + .collect() +} + #[test] fn ruler_units_in_the_program_header_alone_are_kept() { let art = layer("Art", true, &square(10, 10)); @@ -2219,11 +2227,11 @@ fn an_ai_files_ruler_guides_come_in_on_their_layers() { assert!(read, "{w:?}"); let jig = d.layers[0].id; let want = [ - vectorcraft_doc::Guide { vertical: false, pos: 60.0 * k, artboard: None, layer: Some(jig) }, - vectorcraft_doc::Guide { vertical: true, pos: 20.0 * k, artboard: Some(d.artboards[0].id), layer: Some(jig) }, + vectorcraft_doc::Guide { layer: Some(jig), ..vectorcraft_doc::Guide::new(false, 60.0 * k) }, + vectorcraft_doc::Guide { artboard: Some(d.artboards[0].id), layer: Some(jig), ..vectorcraft_doc::Guide::new(true, 20.0 * k) }, ]; - assert_eq!(d.guides, want); - assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the square alone"); + assert_eq!(guide_lines(&d), want); + assert_eq!(d.layers[0].children().map_or(0, |c| c.iter().filter(|c| !c.is_ruler_guide()).count()), 1, "the square alone"); } } @@ -2241,7 +2249,10 @@ fn an_eps_of_its_art_box_keeps_the_guides_of_its_first_artboard() { assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert_eq!(r.document.artboards.len(), 1); let (board, layer) = (r.document.artboards[0].id, r.document.layers[0].id); - assert_eq!(r.document.guides, [vectorcraft_doc::Guide { vertical: true, pos: 25.0, artboard: Some(board), layer: Some(layer) }]); + assert_eq!( + guide_lines(&r.document), + [vectorcraft_doc::Guide { artboard: Some(board), layer: Some(layer), ..vectorcraft_doc::Guide::new(true, 25.0) }] + ); } /// A PDF part read from a `UserUnit` page cropped to the art: the art's box, not the artboard, is diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index c95940b95d..26a46401fd 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -152,7 +152,9 @@ impl<'a> Writer<'a> { self.locked = false; self.line("0 A"); self.in_hidden = !n.visible; - self.contents(children, *clip, false); + // Ruler guides are kept in the document's native copy only. + let art: Vec> = children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(); + self.contents(&art, *clip, false); self.in_hidden = false; self.line("LB\n%AI5_EndLayer--"); true @@ -209,6 +211,10 @@ impl<'a> Writer<'a> { /// One object (inside a container locked when `locked`). fn node(&mut self, n: &Node, locked: bool) { + // Ruler guides are kept in the document's native copy only. + if n.is_ruler_guide() { + return; + } if !n.visible && !self.in_hidden { match self.hidden { HiddenObjects::Omit => { @@ -292,6 +298,8 @@ impl<'a> Writer<'a> { self.nest -= 1; } } + // Ruler guides are kept in the document's native copy only. + NodeKind::RulerGuide { .. } => {} } } diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 974831b703..8c9b04ff80 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -389,6 +389,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } if self.body.len() > MAX_BODY { self.body.truncate(was); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 667b39aad2..38545b8292 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -239,6 +239,9 @@ struct Body { fn body(doc: &Document, o: &SaveOptions) -> Result { let version = o.version; let mut d = doc.without_edit_modes().into_owned(); + // Ruler guides are saved in the document's `guides` list, as before they were nodes, so every + // version reads them. + d.guides_to_saved(); // Older apps don't know placed documents: they get the art the objects show, with its // resources. Otherwise resources only exporting adds are never saved. if version < VERSION && d.has_placed() { @@ -602,6 +605,12 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result ))); } depth_notes.extend(beyond.note()); + // Untrusted: ruler guide nodes only directly in layers, above their art (after the depth limit: + // the walk recurses). + doc.tidy_ruler_guides(); + // The saved ruler guides become nodes on their layers (after the guides beyond the canvas are + // dropped, and after `fix_next_id`: new ids start above the file's). + doc.guides_into_nodes(); // Untrusted: the type's sizes, leading, baseline shifts and scales, its indents and paragraph // spacing, and its tab stops, each within its range (after the depth limit: the walk recurses). doc.keep_type_values_in_range(); @@ -1203,6 +1212,66 @@ mod tests { assert_eq!(load(&bytes).unwrap().node_count(), n, "the real budget opens ordinary documents"); } + /// The saved ruler guides become objects when read, so each counts against the same budget. + #[test] + fn saved_ruler_guides_count_against_the_object_budget() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + for i in 0..50 { + d.add_ruler_guide(&vectorcraft_doc::Guide::new(i % 2 == 0, f64::from(i))); + } + let n = d.node_count() + d.ruler_guides().len(); + let bytes = save_with(&d, &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap(); + let opened = load_file_within(&bytes, n, vectorcraft_doc::node::MAX_BUFFERED_VALUES).unwrap().doc; + assert_eq!(opened.ruler_guides().len(), 50, "a document of exactly the budget opens with its guides"); + let Err(FormatError::NotVectorcraft(why)) = load_file_within(&bytes, n - 1, vectorcraft_doc::node::MAX_BUFFERED_VALUES) else { + panic!("one guide past the budget must be refused") + }; + assert!(why.contains(&format!("more than {} objects", n - 1)), "{why}"); + } + + /// Ruler guide nodes a file holds out of place are put right as it is read: one below a layer's + /// art moves up to the layer's guides, and one in a group, beside the layers or in a symbol is + /// removed. Saving again writes no ruler guide node, which builds from before ruler guides + /// were nodes could not read. + #[test] + fn ruler_guide_nodes_out_of_place_in_a_file_are_put_right() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + let (a, b, group) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let path = |id| Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + d.insert(Some(l), 0, path(a)).unwrap(); + d.insert(Some(l), usize::MAX, Node::group(group, vec![Arc::new(path(b))])).unwrap(); + let li = d.layers.iter().position(|x| x.id == l).unwrap(); + let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + let guide = |id: u64| serde_json::json!({"id": id, "kind": {"type": "rulerguide", "vertical": true, "pos": 5.0}}); + let kids = &mut v["document"]["layers"][li]["kind"]["children"]; + kids.as_array_mut().unwrap().insert(0, guide(50)); + kids[2]["kind"]["children"].as_array_mut().unwrap().push(guide(51)); + v["document"]["layers"].as_array_mut().unwrap().push(guide(52)); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let listed: Vec = opened.ruler_guides().iter().filter_map(|g| g.id).map(|id| id.0).collect(); + assert_eq!(listed, [50]); + let layer = opened.layers.iter().find(|x| x.id == l).unwrap(); + assert_eq!(layer.children().unwrap().iter().map(|c| c.id.0).collect::>(), [a.0, group.0, 50], "art first, then the guide"); + assert_eq!(opened.node(group).unwrap().children().unwrap().len(), 1, "the group keeps only its path"); + assert!(opened.layers.iter().all(|x| !x.is_ruler_guide())); + assert!(!String::from_utf8(save(&opened, false)).unwrap().contains("rulerguide")); + + // A ruler guide in a symbol's art is removed too. + let mut d = Document::new(100.0, 100.0); + let sym = d.alloc_id(); + let art = Node::group(sym, vec![Arc::new(path(d.alloc_id()))]); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art), dynamic: false }); + let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + v["document"]["symbols"][0]["art"]["kind"]["children"].as_array_mut().unwrap().push(guide(60)); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!(opened.symbols[0].art.children().unwrap().len(), 1); + assert!(!opened.symbols[0].art.children().unwrap().iter().any(|c| c.is_ruler_guide())); + } + /// #3587: a reordered file (its objects' fields before their `type`, as a JSON tool that /// sorts keys writes them) still opens; one whose fields before a `type` hold more values than /// the budget, such as a long array of numbers, is refused as they are read. diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs index a5f028c384..2272f42f72 100644 --- a/crates/format/tests/it/main.rs +++ b/crates/format/tests/it/main.rs @@ -3,6 +3,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod links; +mod native_guides; mod native_v3; mod native_v4; mod save_options; diff --git a/crates/format/tests/it/native_guides.rs b/crates/format/tests/it/native_guides.rs new file mode 100644 index 0000000000..614ceded63 --- /dev/null +++ b/crates/format/tests/it/native_guides.rs @@ -0,0 +1,146 @@ +//! Ruler guides in `.vectorcraft` files of every version: the saved `guides` list becomes ruler +//! guide nodes on their layers when a file opens, and is written back as the same list. + +use serde::Deserialize; +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, LayerColor, Node, NodeId}; +use vectorcraft_format::{MIN_VERSION, SaveOptions, VERSION, load, save_with}; +use vectorcraft_testkit::fixtures; + +fn json_of(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).unwrap() +} + +fn save_v(d: &Document, version: u32) -> Vec { + save_with(d, &SaveOptions { version, ..SaveOptions::default() }).unwrap() +} + +/// The document of a saved file without its guide list and its next id (giving guides ids +/// raises the next id). +fn without_guides(v: &Value) -> Value { + let mut d = v["document"].clone(); + let o = d.as_object_mut().unwrap(); + o.remove("guides"); + o.remove("next_id"); + d +} + +/// A document with art on two layers, a sublayer in the bottom one, and two artboards: +/// (document, bottom layer, sublayer, top layer, second artboard's id). +fn doc() -> (Document, NodeId, NodeId, NodeId, u32) { + let mut d = (*fixtures::rich_session().doc().unwrap().doc).clone(); + let bottom = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(bottom), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let top = d.add_layer(Some("Top")); + let mut ab = d.artboards[0].clone(); + ab.id = d.next_artboard_id(); + d.artboards.push(ab); + let board = d.artboards[1].id; + (d, bottom, sub, top, board) +} + +/// Each version's file with a guide on no layer, one on a sublayer, one on a layer that is gone, +/// and an artboard guide, as files written before ruler guides were nodes hold them: it opens +/// with the guides on their layers, saves the same lines with their layers and ids, and saving +/// what that file opens to gives the same `guides` list again. The rest of the document is saved +/// as it was. +#[test] +fn every_version_reads_and_writes_back_the_same_guides() { + let (d, bottom, sub, top, board) = doc(); + for version in MIN_VERSION..=VERSION { + let mut v = json_of(&save_v(&d, version)); + assert_eq!(v["version"], version); + let plain = without_guides(&v); + v["document"]["guides"] = json!([ + {"vertical": true, "pos": 12.5}, + {"vertical": false, "pos": 40.0, "layer": sub}, + {"vertical": true, "pos": 70.0, "layer": 99_999}, + {"vertical": false, "pos": 90.0, "layer": bottom, "artboard": board}, + ]); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let guides = opened.ruler_guides(); + let lines: Vec<_> = guides.iter().map(|g| (g.vertical, g.pos, g.artboard, g.layer)).collect(); + // On no layer and on a deleted layer: the top visible, unlocked layer. + assert_eq!( + lines, + [(true, 12.5, None, Some(top)), (false, 40.0, None, Some(sub)), (true, 70.0, None, Some(top)), (false, 90.0, Some(board), Some(bottom))], + "version {version}" + ); + for g in &guides { + let n = opened.node(g.id.unwrap()).unwrap(); + assert!(n.is_ruler_guide() && n.visible && !n.locked && n.name.is_none(), "version {version}"); + // Each sits at the top of its layer, above the art. + let kids = opened.node(g.layer.unwrap()).unwrap().children().unwrap(); + let first_guide = kids.iter().position(|c| c.is_ruler_guide()).unwrap(); + assert!(kids[first_guide..].iter().all(|c| c.is_ruler_guide()), "version {version}"); + } + + let once = json_of(&save_v(&opened, version)); + assert_eq!(once["version"], version); + let saved: Vec<_> = once["document"]["guides"] + .as_array() + .unwrap() + .iter() + .map(|g| (g["vertical"].clone(), g["pos"].clone(), g.get("artboard").cloned(), g["layer"].clone(), g["id"].is_u64())) + .collect(); + assert_eq!( + saved, + [ + (json!(true), json!(12.5), None, json!(top), true), + (json!(false), json!(40.0), None, json!(sub), true), + (json!(true), json!(70.0), None, json!(top), true), + (json!(false), json!(90.0), Some(json!(board)), json!(bottom), true), + ], + "version {version}" + ); + assert_eq!(without_guides(&once), plain, "version {version}: the rest of the document"); + + let reopened = load(&serde_json::to_vec(&once).unwrap()).unwrap(); + assert_eq!(reopened.ruler_guides(), guides, "version {version}: same ids, layers and lines"); + let twice = json_of(&save_v(&reopened, version)); + assert_eq!(twice["document"]["guides"], once["document"]["guides"], "version {version}"); + } +} + +/// The guide entry of a build from before ruler guides were nodes, field for field. +#[derive(Debug, PartialEq, Deserialize)] +struct OldGuide { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + #[serde(default)] + layer: Option, +} + +/// An older build reads the guides a file now saves, the new fields (id, name, lock, visibility) +/// included, as the same lines on the same layers. +#[test] +fn an_older_guide_entry_reads_the_saved_guides() { + let (mut d, bottom, sub, _, board) = doc(); + let mut g = vectorcraft_doc::Guide::new(true, 15.0); + g.layer = Some(sub); + g.name = Some("Fold".into()); + g.locked = true; + g.hidden = true; + d.add_ruler_guide(&g); + d.add_ruler_guide(&vectorcraft_doc::Guide { layer: Some(bottom), artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 25.0) }); + for version in MIN_VERSION..=VERSION { + let v = json_of(&save_v(&d, version)); + let entries = v["document"]["guides"].as_array().unwrap(); + assert_eq!(entries[0]["name"], "Fold", "the new fields are written"); + let old: Vec = serde_json::from_value(v["document"]["guides"].clone()).unwrap(); + assert_eq!( + old, + [ + OldGuide { vertical: true, pos: 15.0, artboard: None, layer: Some(sub) }, + OldGuide { vertical: false, pos: 25.0, artboard: Some(board), layer: Some(bottom) }, + ], + "version {version}" + ); + // And this build reads its own lock, visibility and name back. + let back = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!(back.ruler_guides(), d.ruler_guides(), "version {version}"); + } +} diff --git a/crates/format/tests/it/native_v3.rs b/crates/format/tests/it/native_v3.rs index 6a96fae79d..512025375e 100644 --- a/crates/format/tests/it/native_v3.rs +++ b/crates/format/tests/it/native_v3.rs @@ -293,13 +293,21 @@ fn artboard_guides_round_trip_and_canvas_guides_save_as_before() { use vectorcraft_doc::Guide; let mut d = Document::new(100.0, 100.0); let id = d.artboards[0].id; - d.guides = vec![Guide::new(true, 10.0), Guide { artboard: Some(id), ..Guide::new(false, 20.0) }]; + d.add_ruler_guide(&Guide::new(true, 10.0)); + d.add_ruler_guide(&Guide { artboard: Some(id), ..Guide::new(false, 20.0) }); let mut v = json_of(&save(&d, false)); - assert_eq!(v["document"]["guides"], json!([{"vertical": true, "pos": 10.0}, {"vertical": false, "pos": 20.0, "artboard": id}])); - assert_eq!(load(&save(&d, false)).unwrap().guides, d.guides); + let lines: Vec<_> = v["document"]["guides"] + .as_array() + .unwrap() + .iter() + .map(|g| json!({"vertical": g["vertical"], "pos": g["pos"], "artboard": g.get("artboard")})) + .collect(); + assert_eq!(lines, [json!({"vertical": true, "pos": 10.0, "artboard": null}), json!({"vertical": false, "pos": 20.0, "artboard": id})]); + assert_eq!(load(&save(&d, false)).unwrap().ruler_guides(), d.ruler_guides()); v["document"]["guides"] = json!([{"vertical": false, "pos": 20.0}]); let old = load(&serde_json::to_vec(&v).unwrap()).unwrap(); - assert_eq!(old.guides, [Guide::new(false, 20.0)]); + let old_lines: Vec<_> = old.ruler_guides().into_iter().map(|g| (g.vertical, g.pos, g.artboard)).collect(); + assert_eq!(old_lines, [(false, 20.0, None)]); } #[test] diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 87398c1f07..343b422093 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -176,7 +176,8 @@ impl<'a> Scene<'a> { } fn node(&mut self, n: &Node, force: bool) { - if let NodeKind::Layer { template: true, .. } = n.kind { + // Ruler guides draw on screen only: no record and no loss warning. + if n.is_ruler_guide() || n.is_template() { return; } if !force && !n.visible { @@ -237,6 +238,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.opacity = opacity; } diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 75b87209c5..143fa848f3 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -92,7 +92,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let links = !set.standard.is_pdfx(); let mut linked = false; for layer in doc.layers.iter().filter(|_| !links) { - layer.walk(&mut |n| linked |= n.url().is_some()); + layer.walk(&mut |n| linked |= !n.is_ruler_guide() && n.url().is_some()); } if linked { warnings.push(format!("{} files have no links: the objects' URLs aren't clickable", set.standard.label())); diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 2d9a9cc175..fde13f4d3c 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -318,6 +318,8 @@ impl Exporter<'_> { self.node(s, c, page, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.knockout = enclosing; for _ in 0..pushes { diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index d0a4619622..2238ab41e0 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -1195,7 +1195,10 @@ impl Plan { if self.top.as_ref().is_some_and(|(p, _)| *p == parent) && let Some((_, nodes)) = self.top.take() { - out.get_or_insert_with(|| list.to_vec()).extend(nodes.into_iter().map(Arc::new)); + // On a layer, new objects go below its ruler guides, which stay on top. + let o = out.get_or_insert_with(|| list.to_vec()); + let at = Node::guide_run_start(o); + o.splice(at..at, nodes.into_iter().map(Arc::new)); } out } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index ba4cccae21..c04ffe6a0c 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -164,6 +164,21 @@ fn compound_guides_are_not_filter_targets() { assert!(targets.contains(&made[1]), "the plain compound path"); } +/// New objects put on top of a layer go below its ruler guides, which stay on top; a ruler guide +/// is never a filter target. +#[test] +fn new_objects_go_below_a_layers_ruler_guides() { + let (mut d, _, layer) = doc(); + let guide = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 5.0, artboard: None }); + let gid = d.insert(Some(layer), usize::MAX, guide).unwrap(); + assert!(!filter_targets(&d, &[layer]).contains(&gid)); + let out = json!({"objects": [{"name": "N", "type": "path", "path": square(0.0)}]}); + let made = apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &keep).unwrap(); + let ids: Vec = d.children(Some(layer)).unwrap().iter().map(|n| n.id).collect(); + assert_eq!(ids.last(), Some(&gid)); + assert_eq!(ids.get(ids.len() - 2), made.first()); +} + /// `guide` and `locked` make objects guides (a compound path's members too) and lock them, or undo /// both; locked objects and guides are not [`selectable`]. Other values are refused. #[test] diff --git a/crates/render/src/encode.rs b/crates/render/src/encode.rs index 201931a8ac..635f906839 100644 --- a/crates/render/src/encode.rs +++ b/crates/render/src/encode.rs @@ -248,7 +248,7 @@ fn drawn_bounds_with(symbols: &SymbolBoxes, n: &Node, held: Option<&Arc>, return None; } match &n.kind { - NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None, + NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => None, NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children .iter() .skip(usize::from(n.extras().shaper.is_some())) @@ -289,7 +289,7 @@ fn without_undrawn(n: &Node) -> Option { if keeps_all || !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { return None; } - let undrawn = |c: &Node| !c.visible || matches!(c.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. }); + let undrawn = |c: &Node| !c.visible || c.is_guide() || c.is_template(); let mut changed = false; let kept: Vec> = n .children()? diff --git a/crates/render/src/encode/psd.rs b/crates/render/src/encode/psd.rs index 777276e696..98c031c89a 100644 --- a/crates/render/src/encode/psd.rs +++ b/crates/render/src/encode/psd.rs @@ -447,7 +447,7 @@ impl Parts<'_> { /// Node `n` (a top-level layer, or with Maximum Editability anything in one) and its parts. fn add(&mut self, n: &Node) { // Templates and guides aren't artwork. - if matches!(n.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. }) || !(n.visible || self.o.hidden_layers) { + if n.is_template() || n.is_guide() || !(n.visible || self.o.hidden_layers) { return; } let (hidden, opacity) = (!n.visible, opacity_byte(n.opacity)); diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs index e56278f1de..a488cceff2 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -271,6 +271,28 @@ fn guides_are_not_layers() { assert_eq!(parse(&export(&b.build(), &o)).count, -3, "the group and the square"); } +/// Ruler guides on a layer and a sublayer change nothing in the export: the same file with and +/// without Maximum Editability, the same drawn bounds and the same cached size of each layer. +#[test] +fn ruler_guides_are_not_exported() { + use vectorcraft_doc::{NodeId, NodeKind}; + let plain = doc(); + let mut guided = plain.clone(); + let guide = |id, vertical, pos| Node::new(NodeId(id), NodeKind::RulerGuide { vertical, pos, artboard: None }); + let (bottom, inner) = (guided.layers[0].id, guided.layers[1].children().unwrap().last().unwrap().id); + guided.insert(Some(bottom), usize::MAX, guide(900, true, 20.0)).unwrap(); + guided.insert(Some(bottom), usize::MAX, guide(901, false, 15.0)).unwrap(); + guided.insert(Some(inner), usize::MAX, guide(902, true, 3.0)).unwrap(); + for psd in [PsdOptions::default(), PsdOptions { max_editability: true, ..PsdOptions::default() }] { + assert_eq!(export(&guided, &opts(psd.clone())), export(&plain, &opts(psd))); + } + assert_eq!(art_bounds(&guided), art_bounds(&plain)); + for (g, p) in guided.layers.iter().zip(&plain.layers) { + assert_eq!(crate::drawn_bounds(&guided, g), crate::drawn_bounds(&plain, p)); + assert_eq!(crate::lru::node_bytes(g), crate::lru::node_bytes(p)); + } +} + #[test] fn a_background_colour_is_the_bottom_layer() { let doc = doc(); diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index 54ab4e51ed..85f327b89b 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -243,10 +243,14 @@ pub(crate) fn path_bytes(p: &BezPath) -> usize { size_of_val(p.elements()) } -/// Approximate bytes of node `n` and its members: the nodes and their path anchors. +/// Approximate bytes of node `n` and its members: the nodes and their path anchors. Ruler guides +/// are not drawn art and are not counted. pub(crate) fn node_bytes(n: &Node) -> usize { let mut b = 0; n.walk(&mut |c| { + if c.is_ruler_guide() { + return; + } b += size_of::() + NODE_OVERHEAD; if let Some(p) = c.path_data() { b += p.subpaths.iter().map(|s| size_of::() + s.anchors.len() * size_of::()).sum::(); diff --git a/crates/render/src/node_checks.rs b/crates/render/src/node_checks.rs index 900a59c836..bc516567dd 100644 --- a/crates/render/src/node_checks.rs +++ b/crates/render/src/node_checks.rs @@ -108,9 +108,11 @@ pub(crate) fn holds_instance(n: &Node) -> bool { } /// Is `a` left out of a frame `doc` draws with `opts`, before any culling: hidden, a template or -/// guide the view hides, drawn hidden, or the instance its symbol is being edited from? +/// guide the view hides, a ruler guide (the canvas draws those), drawn hidden, or the instance its +/// symbol is being edited from? pub(crate) fn left_out(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Document, a: &Node) -> bool { - let view_item = (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) + let view_item = a.is_ruler_guide() + || (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) || (opts.skip_templates || opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. }); !a.visible || view_item || opts.hidden.contains(&a.id) || edited_instance(opts, doc, a.id) } diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index a722c799e2..2372abdbf2 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -361,7 +361,8 @@ impl Renderer { let Some(pe) = &f.doc.pattern_edit else { return false }; let Some(layer) = f.doc.layers.iter().find(|l| l.id == pe.layer) else { return false }; let Some(def) = f.doc.pattern(&pe.pattern) else { return false }; - let children: Vec> = layer.children().cloned().unwrap_or_default(); + // The tile's art: the layer's ruler guides are not part of it. + let children: Vec> = layer.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect(); if layer.visible && !children.is_empty() { let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0); let (before, after) = def.preview_offsets_dimmed_sides(); diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 9b59367deb..ca6611ea5f 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -596,6 +596,8 @@ impl Renderer { Some(a) => self.draw_live_body(ctx, f, &a, true), None => self.draw_live_node(ctx, f, n), }, + // The canvas draws ruler guides over the art; the renderer draws nothing for them. + NodeKind::RulerGuide { .. } => {} } } @@ -680,18 +682,20 @@ impl Renderer { self.draw_knockout(ctx, f, children); } else if let Some((_, path)) = self.isolation_path(f) { // Isolation mode, in a container on the way down to the isolated one: each run of - // members around it draws dimmed as one (#833). + // members around it draws dimmed as one (#833). A run of ruler guides alone draws + // nothing, so it gets no dimmed pass. + let draws = |run: &[Arc]| run.iter().any(|c| !c.is_ruler_guide()); let mut rest = children; while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) { let (around, from) = rest.split_at(i); - if !around.is_empty() { + if draws(around) { self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n))); } let Some((on_path, after)) = from.split_first() else { break }; self.draw_arc(ctx, f, on_path); rest = after; } - if !rest.is_empty() { + if draws(rest) { self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n))); } } else { diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 129bd83e28..746ab84d21 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -633,6 +633,10 @@ impl Writer<'_> { /// An object, inside `` when it links to a URL (Attributes panel). pub(super) fn node(&mut self, n: &Node) { + // Ruler guides are not written, not even as a link. + if n.is_ruler_guide() { + return; + } let layer = usize::from(n.is_layer()); self.layer_nest += layer; match n.url().filter(|_| n.visible && !self.anonymous) { @@ -717,7 +721,7 @@ impl Writer<'_> { self.line(""); } } - NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => {} NodeKind::Path { path, rule, .. } => { if path.is_empty() { return; diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index fb06f80057..4f91d6c970 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -170,6 +170,10 @@ impl Writer<'_> { } let mut named: Vec<(NodeId, String)> = Vec::new(); self.doc.walk(|n| { + // Ruler guides are not written, so their names reserve no id. + if n.is_ruler_guide() { + return; + } if let Some(name) = &n.name { named.push((n.id, sanitize_id(name))); } diff --git a/crates/svg/src/glyph.rs b/crates/svg/src/glyph.rs index c019f9b0a8..22697f58a3 100644 --- a/crates/svg/src/glyph.rs +++ b/crates/svg/src/glyph.rs @@ -44,18 +44,20 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti // usvg resolves `context-fill` and `context-stroke` only for markers. let svg = svg.replace("context-fill", &hex(fill)).replace("context-stroke", &stroke.map_or_else(|| "none".into(), hex)); let (doc, _) = crate::import::import(&svg, &ImportOptions::default(), crate::MAX_NODES).ok()?; + // A layer's art, its ruler guides left out. + let art_of = |l: &Node| l.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect::>(); let mut art = if whole { - Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), l.children().cloned().unwrap_or_default()))).collect()) + Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), art_of(l)))).collect()) } else { // The glyph, top-level, is a layer when a ``, else an object named after its id. let named = |n: &Node| n.name.as_deref() == Some(id.as_str()); match doc.layers.iter().find(|l| named(l)) { - Some(layer) => Node::group(NodeId(0), layer.children().cloned().unwrap_or_default()), + Some(layer) => Node::group(NodeId(0), art_of(layer)), None => { let mut found = None; for layer in &doc.layers { layer.walk(&mut |n| { - if found.is_none() && named(n) { + if found.is_none() && !n.is_ruler_guide() && named(n) { found = Some(n.clone()); } }); @@ -199,7 +201,7 @@ fn own_art(n: &Node, doc: &Document) -> Option { im.pixels = Some(Arc::new(blob.clone())); } if let Some(children) = n.children_mut() { - *children = children.iter().filter_map(|c| own_art(c, doc).map(Arc::new)).collect(); + *children = children.iter().filter(|c| !c.is_ruler_guide()).filter_map(|c| own_art(c, doc).map(Arc::new)).collect(); } Some(n) } diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 56efe7b3a8..ca31f28b85 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -500,20 +500,23 @@ impl Importer { } /// Put `art` in layer `l` at `index` (at most its end) unclipped: a clipping mask on the layer - /// becomes a clip group in it, holding the art it clipped. + /// becomes a clip group in it, holding the art it clipped. The layer's ruler guides stay last, + /// outside the clip group. pub(super) fn add_unclipped(&mut self, l: &mut Node, index: usize, art: Vec>) { if art.is_empty() { return; } if let NodeKind::Layer { children, clip: clip @ true, .. } = &mut l.kind { *clip = false; - let clipped = std::mem::take(children); + let mut clipped = std::mem::take(children); + let guides = clipped.split_off(Node::guide_run_start(&clipped)); if let Some(group) = self.named("", NodeKind::Group { children: clipped, clip: true }) { children.push(Arc::new(group)); } + children.extend(guides); } if let Some(ch) = l.children_mut() { - let i = index.min(ch.len()); + let i = index.min(Node::guide_run_start(ch)); ch.splice(i..i, art); } } diff --git a/crates/svg/tests/it/ids.rs b/crates/svg/tests/it/ids.rs index 0781397fa3..c41dd4bcf7 100644 --- a/crates/svg/tests/it/ids.rs +++ b/crates/svg/tests/it/ids.rs @@ -159,3 +159,21 @@ fn unique_ids_differ_when_only_gradient_stops_differ() { assert!(!ids1.is_empty() && !ids2.is_empty()); assert!(ids1.iter().all(|id| !ids2.contains(id)), "IDs must not collide: {ids1:?} vs {ids2:?}"); } + +/// Ruler guides on each layer, named as a layer and an object above them are, with a URL, are not +/// written: the SVG is the same as without them, with every kind of object id. +#[test] +fn ruler_guides_are_not_written_and_take_no_id() { + let plain = doc("a", 0.0); + let mut guided = plain.clone(); + for (i, (l, name)) in [(guided.layers[0].id, "Top & Front"), (guided.layers[1].id, "Logo mark (v2)")].into_iter().enumerate() { + let mut g = Node::new(guided.alloc_id(), vectorcraft_doc::NodeKind::RulerGuide { vertical: i == 0, pos: 30.0, artboard: None }); + g.name = Some(name.into()); + g.edit_attrs(|a| a.url = "https://example.com/".into()); + guided.insert(Some(l), usize::MAX, g).unwrap(); + } + for object_ids in [ObjectIds::LayerNames, ObjectIds::Minimal, ObjectIds::Unique] { + let opts = ExportOptions { object_ids, ..Default::default() }; + assert_eq!(export(&guided, &opts), export(&plain, &opts), "{object_ids:?}"); + } +} diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index 656bf4a8bb..8deb84a45b 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -263,20 +263,20 @@ impl DocBuilder { } } -/// All non-layer nodes of a document in paint order. +/// All nodes of a document other than layers and ruler guides, in paint order. pub fn art_nodes(doc: &Document) -> Vec<&Node> { let mut v = Vec::new(); doc.walk(|n| { - if !n.is_layer() { + if !n.is_layer() && !n.is_ruler_guide() { v.push(n) } }); v } -/// Ids of the top-level art (direct children of layers), bottom first. +/// Ids of the top-level art (direct children of layers other than ruler guides), bottom first. pub fn top_level_ids(doc: &Document) -> Vec { - doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id)).collect() + doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().filter(|n| !n.is_ruler_guide()).map(|n| n.id)).collect() } /// Every node id (layers included), paint order. diff --git a/crates/testkit/src/invariants.rs b/crates/testkit/src/invariants.rs index c03b33dd53..8791acf428 100644 --- a/crates/testkit/src/invariants.rs +++ b/crates/testkit/src/invariants.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_engine::Session; /// Serialize a document to JSON (the canonical form for equality checks). @@ -11,7 +11,8 @@ pub fn doc_json(doc: &Document) -> Value { serde_json::to_value(doc).expect("document serializes") } -/// Tree invariants: ids unique, `node_count` equals the number of walked nodes, `next_id` above +/// Tree invariants: ids unique, `node_count` equals the number of walked nodes other than ruler +/// guides, ruler guides only at the top of a layer, `next_id` above /// every id, top level made of layers, layers only at the top or inside layers, clip groups have /// a first child, compound children are paths, no image holds its own pixels (the document keeps /// them). @@ -21,11 +22,21 @@ pub fn check_document(doc: &Document) -> Result<(), String> { let mut max = 0u64; let mut err = None; doc.walk(|n| { - walked += 1; + // `node_count` counts objects: ruler guides are left out of it. + walked += usize::from(!n.is_ruler_guide()); max = max.max(n.id.0); if !seen.insert(n.id) && err.is_none() { err = Some(format!("duplicate id {}", n.id)); } + let guides = n.children().map_or(0, |ch| ch.iter().filter(|c| c.is_ruler_guide()).count()); + if guides > 0 && err.is_none() { + let ch = n.children().map(Vec::as_slice).unwrap_or_default(); + if !n.is_layer() { + err = Some(format!("{} holds a ruler guide but is not a layer", n.id)); + } else if ch.len() - Node::guide_run_start(ch) != guides { + err = Some(format!("layer {} has a ruler guide below its art", n.id)); + } + } match &n.kind { NodeKind::Group { children, .. } if children.iter().any(|c| c.is_layer()) && err.is_none() => { err = Some(format!("group {} contains a layer", n.id)); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index d7d4bb85b4..0ac443c731 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -40,7 +40,7 @@ fn a_press_where_a_mask_hides_a_selected_object_drags_it() { #[test] fn direct_and_group_selection_pick_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 100.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 100.0)); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y)); @@ -62,7 +62,7 @@ fn direct_and_group_selection_pick_ruler_guides() { #[test] fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let Some(NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; *guide = true; let (s, p) = (Selection::default(), paint()); @@ -848,7 +848,7 @@ fn crossed_segments_match_testing_a_path_for_each_segment() { #[test] fn marquee_selects_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index c50889d030..41b9f517ee 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -579,7 +579,7 @@ impl Targets { if cx.guides { self.rulers = cx .doc - .guides + .ruler_guides() .iter() .filter(|g| cx.doc.guide_shown(g)) .map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }) diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index c34f9ab7de..a4f3bc49fd 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -442,9 +442,9 @@ fn drawn_points_land_on_intersections() { let id = d.alloc_id(); d.insert(Some(l), 1, Node::path(id, path, Appearance::default_art())).unwrap(); } - d.guides.push(vectorcraft_doc::Guide::new(true, 400.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 400.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 150.0)); let (s, p) = (Selection::default(), paint()); let c = ToolContext { guides: true, ..cx(&d, &s, &p) }; let t = Targets::for_drawing(&d, &[], None, None).styled(&c).with_rulers(&c); @@ -612,8 +612,8 @@ fn bisection_finds_what_a_scan_finds() { #[test] fn drawn_points_snap_to_ruler_guides_with_smart_guides_on() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 313.0)); - d.guides.push(vectorcraft_doc::Guide::new(true, 331.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 313.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 331.0)); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); let mut snap = DrawSnap::default(); diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 3297769382..97d8bed289 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -12,27 +12,34 @@ //! or locked layer, can't be picked. use serde_json::json; +use vectorcraft_doc::{Guide, NodeId}; use vectorcraft_geom::{Point, Rect}; use crate::guides::Targets; use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext}; -/// The ruler guide under `p`: the nearest within the selection tolerance (the newest of equals). +/// The ruler guide under `p`, by its index in `guide.list`: the nearest within the selection +/// tolerance (the newest of equals). pub fn guide_at(cx: &ToolContext, p: Point) -> Option { + guide_under(cx, p).map(|(i, _)| i) +} + +/// [`guide_at`], with the guide. +fn guide_under(cx: &ToolContext, p: Point) -> Option<(usize, Guide)> { if !cx.guides { return None; } let tol = cx.pick_tol(); let off = |pos: f64, vertical: bool| (if vertical { p.x } else { p.y } - pos).abs(); cx.doc - .guides - .iter() + .ruler_guides() + .into_iter() .enumerate() .filter(|(_, g)| cx.doc.guide_editable(g) && cx.doc.guide_passes(g, p, tol)) - .map(|(i, g)| (i, off(g.pos, g.vertical))) - .filter(|(_, d)| *d <= tol) + .map(|(i, g)| (i, off(g.pos, g.vertical), g)) + .filter(|(_, d, _)| *d <= tol) .min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0))) - .map(|(i, _)| i) + .map(|(i, _, g)| (i, g)) } /// What a selection tool's marquee `r` does to the ruler guides, after its action selecting the @@ -42,19 +49,20 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option { pub fn marquee_select(cx: &ToolContext, r: Rect, enclosed: bool) -> Option { let mut guides = guides_in_rect(cx, r); if enclosed { - guides.retain(|i| cx.doc.guides.get(*i).is_some_and(|g| guide_inside(cx, r, g))); + guides.retain(|(_, g)| guide_inside(cx, r, g)); } + let guides: Vec = guides.into_iter().map(|(i, _)| i).collect(); (!guides.is_empty()).then(|| Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))) } -/// The editable ruler guides intersecting marquee rectangle `r`. -fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec { +/// The editable ruler guides intersecting marquee rectangle `r`, with their `guide.list` indexes. +fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec<(usize, Guide)> { if !cx.guides { return vec![]; } cx.doc - .guides - .iter() + .ruler_guides() + .into_iter() .enumerate() .filter(|(_, g)| cx.doc.guide_editable(g)) .filter(|(_, g)| { @@ -64,14 +72,13 @@ fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec { r.min_y() <= g.pos && g.pos <= r.max_y() && cx.doc.guide_span(g).is_none_or(|(x0, x1)| r.max_x() >= x0 && r.min_x() <= x1) } }) - .map(|(i, _)| i) .collect() } /// Does ruler guide `g`, crossed by marquee `r`, run along its line within the marquee? An artboard /// guide does when the marquee spans its artboard; a canvas guide runs across the whole canvas and /// never does. -fn guide_inside(cx: &ToolContext, r: Rect, g: &vectorcraft_doc::Guide) -> bool { +fn guide_inside(cx: &ToolContext, r: Rect, g: &Guide) -> bool { let Some((a, b)) = cx.doc.guide_span(g) else { return false }; let (lo, hi) = if g.vertical { (r.min_y(), r.max_y()) } else { (r.min_x(), r.max_x()) }; lo <= a.min(b) && a.max(b) <= hi @@ -85,6 +92,8 @@ fn guide_cursor(vertical: bool) -> Cursor { #[derive(Clone, Copy, Debug)] struct Drag { index: usize, + /// The guide's node. + id: NodeId, vertical: bool, /// Where the guide was, and where it is now (x of a vertical guide, y of a horizontal one). from: f64, @@ -262,11 +271,12 @@ impl GuideEdit { /// A press `ev` on a ruler guide: it gets selected and the drag starts. None (and nothing /// done) when no guide is there. pub fn press(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { - let index = guide_at(cx, ev.pos)?; - let g = cx.doc.guides.get(index)?; - let (shift, selected) = (ev.mods.shift, cx.selection.guides.contains(&index)); + let (index, g) = guide_under(cx, ev.pos)?; + let id = g.id?; + let (shift, selected) = (ev.mods.shift, cx.selection.guides.contains(&id)); self.drag = Some(Drag { index, + id, vertical: g.vertical, from: g.pos, at: g.pos, @@ -307,7 +317,7 @@ impl GuideEdit { vec![Action::Commit] } else if d.deselect { vec![Action::Exec("guide.select".into(), json!({ "indexes": [d.index], "toggle": true }))] - } else if d.key && cx.selection.key_guide == Some(d.index) { + } else if d.key && cx.selection.key_guide == Some(d.id) { // A click on the key guide again: no key. vec![Action::Exec("select.key".into(), json!({}))] } else if d.key { @@ -349,7 +359,7 @@ impl GuideEdit { if let Some(d) = &self.drag { return Some(guide_cursor(d.vertical).turned(cx.view_angle())); } - guide_at(cx, p).and_then(|i| cx.doc.guides.get(i)).map(|g| guide_cursor(g.vertical).turned(cx.view_angle())) + guide_under(cx, p).map(|(_, g)| guide_cursor(g.vertical).turned(cx.view_angle())) } /// While a guide is dragged: the smart guide it snapped to and its position by the pointer. diff --git a/crates/tools/src/rulerguide/tests.rs b/crates/tools/src/rulerguide/tests.rs index 4b4f1be72c..f80f31c776 100644 --- a/crates/tools/src/rulerguide/tests.rs +++ b/crates/tools/src/rulerguide/tests.rs @@ -10,10 +10,16 @@ fn ev(kind: PointerKind, x: f64, y: f64, mods: Mods) -> PointerEvent { fn guides_doc() -> vectorcraft_doc::Document { let (mut d, _) = doc_with_rect(); - d.guides = vec![Guide::new(true, 300.0), Guide::new(false, 400.0)]; + d.add_ruler_guide(&Guide::new(true, 300.0)); + d.add_ruler_guide(&Guide::new(false, 400.0)); d } +/// The node id of ruler guide `i` of `d` (its index in the guide commands). +fn gid(d: &vectorcraft_doc::Document, i: usize) -> vectorcraft_doc::NodeId { + d.ruler_guides()[i].id.unwrap() +} + #[test] fn picks_the_guide_within_the_tolerance_unless_hidden_or_locked() { let (d, s, p) = (guides_doc(), Selection::default(), paint()); @@ -37,7 +43,7 @@ fn a_drag_moves_the_selected_guides_in_one_step() { assert_eq!(g.press(&c, &ev(PointerKind::Down, 300.0, 50.0, m)), Some(vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))])); // The tool sees the guide selected from now on. let mut s = s; - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let c = cx(&d, &s, &p); let a = g.pointer(&c, &ev(PointerKind::Drag, 340.0, 60.0, m)).unwrap(); assert_eq!(a[0], Action::Begin("Move Guide".into())); @@ -52,7 +58,7 @@ fn a_drag_moves_the_selected_guides_in_one_step() { fn the_dragged_guide_snaps_to_art_ruler_ticks_and_the_grid() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let p = paint(); let drag_to = |c: &ToolContext, x: f64, mods: Mods| { let mut g = GuideEdit::default(); @@ -253,7 +259,7 @@ fn new_ruler_guide_alt_snaps_on_the_effective_axis() { fn with_smart_guides_off_snap_to_point_pulls_guides_onto_anchors() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let p = paint(); let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; let moved_to = |c: &ToolContext, x: f64| { @@ -291,7 +297,7 @@ fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { color: None, guide_label: None, }); - d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }]; + d.add_ruler_guide(&Guide { artboard: Some(id), ..Guide::new(false, 150.0) }); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); assert_eq!(guide_at(&c, Point::new(700.0, 151.0)), Some(0)); @@ -306,7 +312,7 @@ fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { fn shift_click_toggles_and_alt_drag_copies() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0, 1]); + s.set_guides([gid(&d, 0), gid(&d, 1)]); let p = paint(); let c = cx(&d, &s, &p); let (shift, alt) = (Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }); @@ -336,20 +342,20 @@ fn a_click_on_a_guide_selected_with_objects_makes_it_the_key() { }; let mut s = Selection::default(); s.set([rect]); - s.toggle_guide(0); + s.toggle_guide(gid(&d, 0)); let make = Action::Exec("select.key".into(), json!({"guide": 0})); assert_eq!(click(&s, Mods::default()), vec![make]); - s.key_guide = Some(0); + s.key_guide = Some(gid(&d, 0)); assert_eq!(click(&s, Mods::default()), vec![Action::Exec("select.key".into(), json!({}))], "the key guide again"); let shift = Mods { shift: true, ..Default::default() }; assert_eq!(click(&s, shift), vec![Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true}))]); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); assert_eq!(click(&s, Mods::default()), vec![], "a guide alone has nothing to align"); // Not selected yet: the first click selects it. s.set([rect]); assert_eq!(click(&s, Mods::default()), vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]); // A drag moves it. - s.toggle_guide(0); + s.toggle_guide(gid(&d, 0)); let c = cx(&d, &s, &p); let mut g = GuideEdit::default(); g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, Mods::default())); @@ -362,7 +368,7 @@ fn dropped_on_its_ruler_the_guide_is_deleted() { let d = guides_doc(); let p = paint(); let mut s = Selection::default(); - s.set_guides([1]); + s.set_guides([gid(&d, 1)]); // The window shows the document from (0, 0) at 100 %: above y = 0 is the top ruler. let screen = ScreenFrame { origin: Point::ZERO, right: Vec2::new(1.0, 0.0), down: Vec2::new(0.0, 1.0), size: (800.0, 600.0) }; let c = ToolContext { screen: Some(screen), ..cx(&d, &s, &p) }; @@ -457,7 +463,7 @@ fn ruler_guide_shift_move_uses_document_origin() { let paint = paint(); for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; for copy in [false, true] { let mut guide = GuideEdit::default(); @@ -498,7 +504,7 @@ fn ruler_guide_shift_zero_and_invalid_origins_use_zero() { let paint = paint(); for (index, vertical, from, want) in [(0, true, 300.0, 225.0), (1, false, 400.0, 235.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; let mods = Mods { shift: true, ..Default::default() }; let mut new = NewGuide::new(&c, vertical, None); @@ -522,7 +528,7 @@ fn ruler_guide_origin_leaves_other_snap_modes_unchanged() { let paint = paint(); for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; for (c, pointer, expected) in [ (ToolContext { ..off }, Point::new(226.4, 237.6), Point::new(226.4, 237.6)), @@ -561,7 +567,7 @@ fn ruler_guide_origin_readouts_are_relative_for_new_and_moved_guides() { for unit in [vectorcraft_doc::Unit::Points, vectorcraft_doc::Unit::Millimeters] { for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { unit, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; let want = format!("{}: {}", if vertical { "X" } else { "Y" }, c.len(if vertical { expected.x } else { expected.y })); let check = |overlays: Vec| { diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 67f5c91b5d..9069cda939 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -502,7 +502,7 @@ fn click_selects_object() { #[test] fn a_press_on_a_ruler_guide_picks_it_over_the_art() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 150.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -524,7 +524,7 @@ fn a_press_on_a_ruler_guide_picks_it_over_the_art() { #[test] fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let Some(vectorcraft_doc::NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; *guide = true; let s = Selection::default(); @@ -679,7 +679,7 @@ fn box_cursors_turn_with_the_view() { let mut s = Selection::default(); s.add(id); let p = paint(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let mut c = cx(&d, &s, &p); c.screen = turned_view(90.0); assert!((c.view_angle() - 90.0).abs() < 1e-9); @@ -752,7 +752,7 @@ fn escape_cancels_a_move_or_an_alt_drag_copy() { #[test] fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 173.4)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 173.4)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -775,7 +775,7 @@ fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { #[test] fn a_shift_move_lands_the_edges_on_a_ruler_guide() { let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 333.0)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 333.0)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -1118,7 +1118,7 @@ fn a_shift_move_snapped_by_its_grabbed_point_stays_on_its_line() { let other = d.alloc_id(); let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 151.5, 350.0, 201.5)); d.insert(Some(l), 1, vectorcraft_doc::Node::path(other, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 420.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 420.5)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -1132,7 +1132,7 @@ fn a_shift_move_snapped_by_its_grabbed_point_stays_on_its_line() { assert!(moved_by(m, (270.5, 270.5)), "{m:?}"); // Smart Guides on, the selection alone with a ruler guide 1.5 below the grabbed point. let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(false, 330.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 330.5)); let mut s = Selection::default(); s.add(id); let (m, _) = shift_moved(&cx(&d, &s, &p), (329.0, 329.0)); @@ -1259,7 +1259,7 @@ fn a_click_on_a_selected_object_makes_it_the_key() { let mut one = Selection::default(); one.add(rect); assert!(click(&one, 150.0, 150.0).is_empty()); - one.guides = vec![0]; + one.guides = vec![vectorcraft_doc::NodeId(999)]; assert_eq!(click(&one, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]); } @@ -1482,8 +1482,8 @@ fn directional_marquee_uses_screen_direction_and_a_dead_zone() { fn e_during_a_marquee_takes_only_the_guides_inside() { let (mut d, id) = doc_with_rect(); let board = d.artboards[0].id; - d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); - d.guides.push(vectorcraft_doc::Guide { artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 150.0) }); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 150.0) }); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1506,7 +1506,7 @@ fn e_during_a_marquee_takes_only_the_guides_inside() { #[test] fn marquee_selects_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1523,7 +1523,7 @@ fn marquee_selects_ruler_guides() { #[test] fn marquee_selects_only_guide() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 20.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 20.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1540,7 +1540,7 @@ fn marquee_selects_only_guide() { #[test] fn marquee_ignores_locked_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let mut cx_locked = cx(&d, &s, &p); @@ -1769,7 +1769,7 @@ fn handle_drag_snaps_to_anchors_and_ruler_guides_with_snap_to_point() { let other = d.alloc_id(); let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 300.0, 300.0)); d.insert(Some(l), 1, Node::path(other, sq, Appearance::default_art())).unwrap(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 230.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 230.5)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -2436,7 +2436,7 @@ fn a_thin_shapes_centre_point_gives_the_handles_the_pointer() { #[test] fn a_ruler_guide_under_the_centre_point_keeps_the_pointer() { let (mut d, id) = doc_with_live_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 150.0)); let mut s = Selection::default(); s.add(id); let p = paint(); diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index 8b092d8ad0..69bbf9051f 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -41,9 +41,9 @@ fn has_area(r: Rect) -> bool { fn snap(cx: &ToolContext, p: Point) -> (Point, Vec) { let (mut q, ov) = crate::guides::snap_draw(cx, p, &[]); let tol = cx.tol(5.0); + let guides = cx.doc.ruler_guides(); let nearest = |vertical: bool, v: f64| { - cx.doc - .guides + guides .iter() .filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_shown(g) && cx.doc.guide_passes(g, p, tol)) .min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs())) @@ -390,7 +390,7 @@ mod tests { #[test] fn the_slice_tool_drags_out_a_slice_in_one_undo_step() { let (mut d, _) = doc_with_rect(); - d.guides.push(Guide::new(true, 403.0)); + d.add_ruler_guide(&Guide::new(true, 403.0)); let s = Selection::default(); let p = paint(); let mut c = cx(&d, &s, &p); @@ -557,7 +557,7 @@ mod tests { assert!(near(resize(&neither, (60.0, 25.0), (97.0, 25.0), none), Rect::new(10.0, 10.0, 97.0, 40.0))); // A ruler guide at x = 98 takes the side dragged 1 px from it, over the rectangle's edge // 1 px the other way, with Smart Guides on or off. - d.guides.push(Guide::new(true, 98.0)); + d.add_ruler_guide(&Guide::new(true, 98.0)); for c in [cx(&d, &sel, &p), ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &sel, &p) }] { assert!(near(resize(&c, (60.0, 25.0), (99.0, 25.0), none), Rect::new(10.0, 10.0, 98.0, 40.0))); } diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index e3a0090561..cccb1ebcae 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -281,7 +281,7 @@ mod tests { #[test] fn shift_drag_meets_a_ruler_guide() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 50.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 50.0)); let s = Selection::default(); let p = paint(); let mut cx = cx(&d, &s, &p); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56f121ad6d..50136f1a9c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1119,11 +1119,12 @@ fn guides_of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, (painter, let look = LineLook::guides(&app.session.prefs, t); let picked = LineLook { color: t.selection, ..look }; let labels = guide_labels(doc); - let selected: &[usize] = &st.selection.guides; + let selected: &[vectorcraft_doc::NodeId] = &st.selection.guides; let key = st.selection.key_guide; let view = view_bounds(xf); // Guides on a hidden layer hide with it. - for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { + for g in doc.ruler_guides().iter().filter(|g| doc.guide_shown(g)) { + let Some(i) = g.id else { continue }; let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) }); // An artboard guide runs across its artboard, a canvas guide across the window (the part // of its document line that the canvas shows, turned with the view). diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 81008bb0be..efe59c127e 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1192,7 +1192,7 @@ fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // From the top ruler: a horizontal guide where the button was released. let to = pos2(rect.center().x, rect.top() + 120.0); drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), to); @@ -1234,8 +1234,8 @@ fn artboard_guide_label_colors_only_its_own_guides() { { let st = app.session.active_mut().unwrap(); let doc = std::sync::Arc::make_mut(&mut st.doc); - let last = doc.guides.len() - 1; - doc.guides[last].artboard = Some(gone); + let last = doc.ruler_guides().last().and_then(|g| g.id).unwrap(); + assert!(doc.edit_ruler_guide(last, |g| g.artboard = Some(gone))); } app.ui.view.guides = true; let ctx = egui::Context::default(); @@ -1304,7 +1304,7 @@ fn a_guide_dragged_out_of_a_ruler_shows_while_dragged() { }; assert!(shapes(&mut app, &ctx).iter().any(across), "real-time drawing {real_time}: the guide shows while dragged"); frame(&mut app, &ctx, vec![button(at, false)]); - assert_eq!(app.session.active().unwrap().doc.guides.len(), 1, "real-time drawing {real_time}: released, it is made"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), 1, "real-time drawing {real_time}: released, it is made"); } } @@ -1383,7 +1383,7 @@ fn new_ruler_guides_follow_alt_through_preview_release_and_undo() { frame(&mut app, &ctx, vec![button(from, true, initially_alt)]); frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); let assert_guide = |app: &VectorcraftApp, alt: bool, pos: Point| { - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); assert_eq!(guides[0].vertical, source_vertical != alt); let expected = if source_vertical != alt { pos.x } else { pos.y }; @@ -1463,7 +1463,7 @@ fn new_ruler_guide_alt_cancel_keeps_document_and_redo() { frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(from)]); frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); assert_eq!(guides[0].vertical, !source_vertical); let artboard = (tool == "artboard").then_some(before.artboards[0].id); @@ -1474,7 +1474,7 @@ fn new_ruler_guide_alt_cancel_keeps_document_and_redo() { assert_eq!(history(&app), before_history); app.session.execute("edit.redo", &json!({})).unwrap(); assert!(app.session.active().unwrap().doc.art_bounds().is_some()); - assert!(app.session.active().unwrap().doc.guides.is_empty()); + assert!(app.session.active().unwrap().doc.ruler_guides().is_empty()); } } } @@ -1513,14 +1513,14 @@ fn cmd_ruler_drags_make_artboard_guides_with_the_preference_on() { let board = app.session.active().unwrap().doc.artboards[0].id; drag(&mut app, Point::new(80.0, 120.0)); let st = app.session.active().unwrap(); - assert_eq!(st.doc.guides.len(), 1, "vertical {source_vertical}, on {on}"); - assert_eq!(st.doc.guides[0].vertical, source_vertical); - assert_eq!(st.doc.guides[0].artboard, on.then_some(board), "vertical {source_vertical}, on {on}"); + assert_eq!(st.doc.ruler_guides().len(), 1, "vertical {source_vertical}, on {on}"); + assert_eq!(st.doc.ruler_guides()[0].vertical, source_vertical); + assert_eq!(st.doc.ruler_guides()[0].artboard, on.then_some(board), "vertical {source_vertical}, on {on}"); // Beyond the artboard's right or bottom edge. let beyond = xf.to_doc(rect.right_bottom() - egui::vec2(2.0, 2.0)); assert!(beyond.x > 400.0 && beyond.y > 300.0, "{beyond:?}"); drag(&mut app, beyond); - assert_eq!(app.session.active().unwrap().doc.guides.len(), if on { 1 } else { 2 }, "vertical {source_vertical}, on {on}"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), if on { 1 } else { 2 }, "vertical {source_vertical}, on {on}"); } } } @@ -1547,7 +1547,7 @@ fn guides_from_the_rulers_snap_and_artboard_guides_span_their_artboard() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // 3 px off the middle of the top and bottom sides: onto the line through them. let mid = xf.to_screen(Point::new(200.0, 150.0)); drag(&mut app, pos2(rect.left() - RULER / 2.0, mid.y), mid + vec2(3.0, 40.0)); @@ -1596,7 +1596,7 @@ fn the_selection_tool_moves_and_deletes_ruler_guides() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // Two guides out of the rulers. let (h, v) = (pos2(rect.center().x + 30.0, rect.center().y + 20.0), pos2(rect.center().x - 40.0, rect.center().y)); drag(&mut app, pos2(h.x, rect.top() - RULER / 2.0), h); @@ -1607,7 +1607,7 @@ fn the_selection_tool_moves_and_deletes_ruler_guides() { drag(&mut app, h, to); let st = app.session.active().unwrap(); assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(v).x)]); - assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![0], vec![])); + assert_eq!((vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), st.selection.objects.clone()), (vec![0], vec![])); assert_eq!(st.history.undo.last().unwrap().label, "Move Guide"); let picked = Tokens::get(&ctx).selection; let s = shapes(&mut app, &ctx); @@ -9512,7 +9512,7 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); let check = |app: &VectorcraftApp, switched: bool, expected: Point| { - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); let vertical = source_vertical != switched; assert_eq!(guides[0].vertical, vertical); @@ -9522,7 +9522,7 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { assert_eq!(history(&app).0, original_history.0, "preview has no undo entry"); if cancel { // Cancellation is a baseline positive control, independent of snap correctness. - assert_eq!(app.session.active().unwrap().doc.guides.len(), 1); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), 1); frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]); assert_eq!(app.session.active().unwrap().doc, before); assert_eq!(app.session.active().unwrap().selection, selection); @@ -9543,7 +9543,17 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { check(&app, alt, release); let st = app.session.active().unwrap(); assert_eq!(st.doc.ruler_origin_for(0), origin); - assert_eq!(st.doc.layers, before.layers, "the rectangle tool draws no artwork"); + let art = |d: &vectorcraft_doc::Document| { + d.layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect::>() + }; + assert_eq!(art(&st.doc), art(&before), "the rectangle tool draws no artwork"); assert_eq!(st.selection, selection); assert_eq!(history(&app), (original_history.0 + 1, 0, true)); assert_eq!(st.history.undo.last().unwrap().label, "New Guide"); @@ -9621,8 +9631,9 @@ fn ruler_guide_origin_tooltip_matches_selected_guide_fields() { frame(&mut app, &ctx, vec![Event::PointerMoved(from)]); frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); - assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4); - assert_eq!(app.session.active().unwrap().selection.guides, vec![0]); + assert!((app.session.active().unwrap().doc.ruler_guides()[0].pos - target).abs() < 1e-4); + let st = app.session.active().unwrap(); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), vec![0]); // Observe the real selected-guide field; no expected strings are inserted into UI state. let fields = egui::Context::default(); crate::theme::install_fonts(&fields); @@ -9652,7 +9663,7 @@ fn ruler_guide_origin_tooltip_matches_selected_guide_fields() { assert_eq!(app.session.active().unwrap().history.undo.len(), undo); frame(&mut app, &ctx, vec![button(to, false)]); assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); - assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute"); + assert!((app.session.active().unwrap().doc.ruler_guides()[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute"); app.run("edit.undo", json!({})).unwrap(); assert_eq!(app.session.active().unwrap().doc, before); } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 452fec1cc0..d0b06cf325 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -64,6 +64,18 @@ use crate::theme::Tokens; use serde_json::json; use vectorcraft_engine::Session; +/// `layers` without their ruler guides: the art. +fn art_of(layers: &[Arc]) -> Vec { + layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect() +} + #[test] fn help_bar_and_status_bar_render_independently() { for (help, status, screen_mode) in [(true, true, 0), (false, true, 0), (true, false, 0), (false, false, 0), (true, true, 3)] { @@ -1556,8 +1568,8 @@ fn artboard_document_tab_roundtrip_properties_delete_uses_shown_board() { assert_eq!(selected_before_delete, json!([1])); assert_eq!(active_before_delete, json!(1)); assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "the original first board survives"); - assert_eq!(st.doc.guides, vec![before.guides[0].clone()], "only the deleted board's guide is removed"); - assert_eq!(st.doc.layers, before.layers, "artwork and symbol instances stay unchanged"); + assert_eq!(st.doc.ruler_guides(), vec![before.ruler_guides()[0].clone()], "only the deleted board's guide is removed"); + assert_eq!(art_of(&st.doc.layers), art_of(&before.layers), "artwork and symbol instances stay unchanged"); assert_eq!(st.doc.symbols, before.symbols, "symbol definitions stay unchanged"); assert_eq!(st.selection, selection); assert_eq!(app.run_count, commands.wrapping_add(1), "one command from the actual Properties control"); @@ -1853,9 +1865,9 @@ fn delete_shown_board(app: &mut crate::VectorcraftApp, ctx: &egui::Context, base "ARTBOARD_CLOSE_TARGET_WRONG_SURVIVOR: Properties showing Shown must delete Shown after closing B" ); assert_eq!(st.doc.as_ref(), expected, "only Shown and its attached guide may be removed from the full document"); - assert_eq!(st.doc.layers, before.layers, "all artwork and symbol instances stay unchanged"); + assert_eq!(art_of(&st.doc.layers), art_of(&before.layers), "all artwork and symbol instances stay unchanged"); assert_eq!(st.doc.symbols, before.symbols); - assert_eq!(st.doc.guides, vec![before.guides[0].clone()]); + assert_eq!(st.doc.ruler_guides(), vec![before.ruler_guides()[0].clone()]); assert_eq!(&st.selection, selection); assert_eq!(st.uid, *uid); assert_eq!(st.revision, revision + 1); @@ -1915,13 +1927,14 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() { let history_before = history_of(st); let history_limits = (st.history.limit, st.history.byte_budget); assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::>(), vec!["Keep", "Shown"]); - assert_eq!(before.guides.len(), 2); + assert_eq!(before.ruler_guides().len(), 2); assert!(!selection.objects.is_empty()); assert!(history_before.1.is_empty()); assert!(!st.is_dirty()); let mut expected = before.as_ref().clone(); expected.artboards = vec![before.artboards[0].clone()]; - expected.guides = vec![before.guides[0].clone()]; + let keep = before.ruler_guides()[0].id; + expected.retain_guides(&mut vectorcraft_doc::Selection::default(), |g| g.id == keep); let base = Baseline { uid, revision, before, selection, history_before, history_limits, expected }; close_b_tab(&mut app, &ctx, &base); delete_shown_board(&mut app, &ctx, &base); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 2ad8bdf213..a7d62b6daf 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -32,7 +32,7 @@ fn open(app: &mut VectorcraftApp, kind: &str, fields: serde_json::Value) { } fn objects(app: &VectorcraftApp) -> usize { - app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.iter().filter(|c| !c.is_ruler_guide()).count())).sum() } #[test] @@ -570,11 +570,11 @@ fn split_into_grid_opens_at_the_selections_size_and_previews() { ]); frame(&mut app, Default::default()); // The preview: 2 × 3 cells and the guides along their edges, not yet an undo step. - assert_eq!((objects(&app), app.session.doc().unwrap().doc.guides.len()), (6, 10)); + assert_eq!((objects(&app), app.session.doc().unwrap().doc.ruler_guides().len()), (6, 10)); assert!(app.session.in_interaction()); confirm(&mut app).unwrap(); let st = app.session.doc().unwrap(); - assert_eq!((objects(&app), st.doc.guides.len(), st.history.undo.len()), (6, 10, undo + 1)); + assert_eq!((objects(&app), st.doc.ruler_guides().len(), st.history.undo.len()), (6, 10, undo + 1)); assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); // Cancel rolls a preview back. app.run("select.all", json!({})).unwrap(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e69dc7b97c..b6ac6977a5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4755,6 +4755,8 @@ axis Both И двете @msg invalid object id {value}: expected a non-negative integer невалиден идентификатор на обект {value}: очаква се неотрицателно цяло число @msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} на `{key}` е {value}, а не идентификатор на обект (неотрицателно цяло число) @msg no such object id {value} няма обект с идентификатор {value} +@msg id {value} is a ruler guide, not an object идентификатор {value} е на водач, а не на обект +@msg a ruler guide goes only directly in a layer or sublayer водачът може да е само пряко в слой или подслой @msg automation filesystem access is not granted: read authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за четене: {path} @msg automation filesystem access is not granted: write authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за запис: {path} @msg automation path rejected: outside the read root {root}: {path} пътят за автоматизация е отхвърлен: извън корена за четене {root}: {path} diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index fc4610e0d0..1f452ccc01 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5110,6 +5110,8 @@ imageTrace Outline Obris @msg invalid object id {value}: expected a non-negative integer nevažeći ID objekta {value}: očekuje se nenegativan cijeli broj @msg `{key}` entry {index} is {value}, not a non-negative integer object id stavka {index} u `{key}` je {value}, a ne ID objekta (nenegativan cijeli broj) @msg no such object id {value} nema objekta s ID-om {value} +@msg id {value} is a ruler guide, not an object ID {value} je vodilica, a ne objekat +@msg a ruler guide goes only directly in a layer or sublayer vodilica može biti samo direktno u sloju ili podsloju @msg id {value} is a layer, not an object id {value} je sloj, a ne objekat @msg id {value} is a layer, not an object; move layers with layer.move id {value} je sloj, a ne objekat; slojeve pomjerajte s layer.move @msg automation filesystem access is not granted: read authority is absent: {path} pristup datotečnom sistemu za automatizaciju nije odobren: nema ovlaštenja za čitanje: {path} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 44562ac0f1..35220f435e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5518,6 +5518,8 @@ digits Malayalam Malaiàlam @msg no such document no existeix aquest document @msg no such layer or object no existeix aquesta capa o objecte @msg no such object id {value} no existeix l’id d’objecte {value} +@msg id {value} is a ruler guide, not an object l’id {value} és una guia, no un objecte +@msg a ruler guide goes only directly in a layer or sublayer una guia només pot anar directament en una capa o subcapa @msg no such object {0} no existeix l’objecte {0} @msg no such perspective grid preset no existeix aquest preajust de quadrícula de perspectiva @msg no such recent file no existeix aquest fitxer recent diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e42f9bd215..2da7d613da 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5207,6 +5207,8 @@ imageTrace Outline Obrys @msg invalid object id {value}: expected a non-negative integer neplatné ID objektu {value}: očekává se nezáporné celé číslo @msg `{key}` entry {index} is {value}, not a non-negative integer object id položka {index} v `{key}` je {value}, nikoli ID objektu (nezáporné celé číslo) @msg no such object id {value} objekt s ID {value} neexistuje +@msg id {value} is a ruler guide, not an object ID {value} patří vodítku, ne objektu +@msg a ruler guide goes only directly in a layer or sublayer vodítko může být jen přímo ve vrstvě nebo podvrstvě @msg id {value} is a layer, not an object ID {value} je vrstva, nikoli objekt @msg id {value} is a layer, not an object; move layers with layer.move ID {value} je vrstva, nikoli objekt; vrstvy přesunete pomocí layer.move @msg automation filesystem access is not granted: read authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění ke čtení: {path} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index cf7aa67b3f..1d8f6c4b00 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4802,6 +4802,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer ugyldigt objekt-id {value}: et ikke-negativt heltal var forventet @msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} i `{key}` er {value}, ikke et objekt-id (et ikke-negativt heltal) @msg no such object id {value} der findes intet objekt med id {value} +@msg id {value} is a ruler guide, not an object id {value} er en hjælpelinje, ikke et objekt +@msg a ruler guide goes only directly in a layer or sublayer en hjælpelinje kan kun ligge direkte i et lag eller underlag @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: læserettighed mangler: {path} @msg automation filesystem access is not granted: write authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: skriverettighed mangler: {path} @msg automation path rejected: outside the read root {root}: {path} automatiseringssti afvist: uden for læseroden {root}: {path} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b339033e66..708e3a4fac 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4863,6 +4863,8 @@ theme Appearance Erscheinungsbild @msg invalid object id {value}: expected a non-negative integer ungültige Objekt-ID {value}: erwartet wird eine nicht negative ganze Zahl @msg `{key}` entry {index} is {value}, not a non-negative integer object id Eintrag {index} von `{key}` ist {value}, keine Objekt-ID (nicht negative ganze Zahl) @msg no such object id {value} kein Objekt mit der ID {value} +@msg id {value} is a ruler guide, not an object die ID {value} gehört zu einer Hilfslinie, nicht zu einem Objekt +@msg a ruler guide goes only directly in a layer or sublayer eine Hilfslinie kann nur direkt in einer Ebene oder Unterebene liegen @msg id {value} is a layer, not an object die ID {value} ist eine Ebene, kein Objekt @msg id {value} is a layer, not an object; move layers with layer.move die ID {value} ist eine Ebene, kein Objekt; verschieben Sie Ebenen mit layer.move @msg automation filesystem access is not granted: read authority is absent: {path} Dateisystemzugriff für die Automatisierung ist nicht gewährt: Die Leseberechtigung fehlt: {path} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 114ed5a524..f5eb0b86d4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4886,6 +4886,8 @@ digits Malayalam Μαλαγιάλαμ @msg invalid object id {value}: expected a non-negative integer μη έγκυρο αναγνωριστικό αντικειμένου {value}: αναμένεται μη αρνητικός ακέραιος @msg `{key}` entry {index} is {value}, not a non-negative integer object id το στοιχείο {index} του `{key}` είναι {value}, όχι αναγνωριστικό αντικειμένου (μη αρνητικός ακέραιος) @msg no such object id {value} δεν υπάρχει αντικείμενο με αναγνωριστικό {value} +@msg id {value} is a ruler guide, not an object το αναγνωριστικό {value} ανήκει σε οδηγό, όχι σε αντικείμενο +@msg a ruler guide goes only directly in a layer or sublayer ένας οδηγός μπορεί να βρίσκεται μόνο απευθείας σε επίπεδο ή υποεπίπεδο @msg id {value} is a layer, not an object το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο @msg id {value} is a layer, not an object; move layers with layer.move το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο· μετακινήστε τα επίπεδα με το layer.move @msg automation filesystem access is not granted: read authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα ανάγνωσης: {path} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c0e50d82e9..633d8d39b3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4925,6 +4925,8 @@ axis Both Ambos @msg invalid object id {value}: expected a non-negative integer identificador de objeto no válido {value}: se esperaba un entero no negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id la entrada {index} de `{key}` es {value}, no un identificador de objeto (un entero no negativo) @msg no such object id {value} no existe el objeto con el identificador {value} +@msg id {value} is a ruler guide, not an object el identificador {value} es de una guía, no de un objeto +@msg a ruler guide goes only directly in a layer or sublayer una guía solo puede estar directamente en una capa o subcapa @msg id {value} is a layer, not an object el identificador {value} es una capa, no un objeto @msg id {value} is a layer, not an object; move layers with layer.move el identificador {value} es una capa, no un objeto; mueva las capas con layer.move @msg automation filesystem access is not granted: read authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de lectura: {path} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index cdbd875764..64f64bcf81 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5109,6 +5109,8 @@ imageTrace Outline Ääriviiva @msg invalid object id {value}: expected a non-negative integer virheellinen objektitunnus {value}: odotettiin ei-negatiivista kokonaislukua @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-luettelon kohde {index} on {value}, ei objektitunnus (ei-negatiivinen kokonaisluku) @msg no such object id {value} objektia tunnuksella {value} ei ole +@msg id {value} is a ruler guide, not an object tunnus {value} on apuviivan, ei objektin +@msg a ruler guide goes only directly in a layer or sublayer apuviiva voi olla vain suoraan tasossa tai alatasossa @msg id {value} is a layer, not an object tunnus {value} on taso, ei objekti @msg id {value} is a layer, not an object; move layers with layer.move tunnus {value} on taso, ei objekti; siirrä tasoja komennolla layer.move @msg automation filesystem access is not granted: read authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: lukuoikeus puuttuu: {path} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index cb81c4902c..2ad984ee88 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4932,6 +4932,8 @@ axis Both Les deux @msg invalid object id {value}: expected a non-negative integer identifiant d’objet {value} non valide (un entier positif ou nul est attendu) @msg `{key}` entry {index} is {value}, not a non-negative integer object id l’entrée {index} de `{key}` vaut {value}, pas un identifiant d’objet (entier positif ou nul) @msg no such object id {value} aucun objet ne porte l’identifiant {value} +@msg id {value} is a ruler guide, not an object l’identifiant {value} est celui d’un repère, pas d’un objet +@msg a ruler guide goes only directly in a layer or sublayer un repère ne peut se trouver que directement dans un calque ou un sous-calque @msg id {value} is a layer, not an object l’identifiant {value} désigne un calque, pas un objet @msg id {value} is a layer, not an object; move layers with layer.move l’identifiant {value} désigne un calque, pas un objet ; déplacez les calques avec layer.move @msg automation filesystem access is not granted: read authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation de lecture : {path} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 296f18e805..fe87b81073 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6944,6 +6944,8 @@ antiAlias Sharp शार्प @msg no such layer or object ऐसी कोई लेयर या ऑब्जेक्ट नहीं @msg no such object {0} ऐसी कोई ऑब्जेक्ट नहीं {0} @msg no such object id {value} ऐसी कोई ऑब्जेक्ट नहीं id {value} +@msg id {value} is a ruler guide, not an object id {value} एक गाइड है, ऑब्जेक्ट नहीं +@msg a ruler guide goes only directly in a layer or sublayer गाइड केवल सीधे किसी लेयर या सबलेयर में हो सकता है @msg no such objects ऐसी कोई ऑब्जेक्ट नहीं @msg no such path anchor, or the path would reach past the canvas इस तरह के कोई पाथ एंकर नहीं, या पाथ कैनवास तक पहुंच जाएगा @msg no such perspective grid preset ऐसा कोई पर्सपेक्टिव ग्रिड पूर्व निर्धारित नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 34b6be0ab7..e46bd20911 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5222,6 +5222,8 @@ imageTrace Outline Körvonal @msg invalid object id {value}: expected a non-negative integer érvénytelen objektumazonosító: {value} (nemnegatív egész szám kell) @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` {index} indexű eleme {value}, nem objektumazonosító (nemnegatív egész szám kell) @msg no such object id {value} nincs ilyen objektumazonosító: {value} +@msg id {value} is a ruler guide, not an object ez az azonosító segédvonalé, nem objektumé: {value} +@msg a ruler guide goes only directly in a layer or sublayer segédvonal csak közvetlenül rétegben vagy alrétegben lehet @msg id {value} is a layer, not an object a(z) {value} azonosító egy réteg, nem objektum @msg id {value} is a layer, not an object; move layers with layer.move a(z) {value} azonosító egy réteg, nem objektum; a rétegeket a layer.move paranccsal helyezheti át @msg object {0} cannot have children ennek az objektumnak nem lehetnek gyermekei: {0} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 16854b5bb7..292819a1e8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4802,6 +4802,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer id objek {value} tidak valid: harus berupa bilangan bulat non-negatif @msg `{key}` entry {index} is {value}, not a non-negative integer object id entri {index} dari `{key}` adalah {value}, bukan id objek (bilangan bulat non-negatif) @msg no such object id {value} tidak ada objek dengan id {value} +@msg id {value} is a ruler guide, not an object id {value} adalah panduan, bukan objek +@msg a ruler guide goes only directly in a layer or sublayer panduan hanya dapat berada langsung di lapisan atau sublapisan @msg automation filesystem access is not granted: read authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas baca tidak ada: {path} @msg automation filesystem access is not granted: write authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas tulis tidak ada: {path} @msg automation path rejected: outside the read root {root}: {path} jalur otomatisasi ditolak: di luar akar baca {root}: {path} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6a8f3d7253..5c8be8c905 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4968,6 +4968,8 @@ axis Both Entrambi @msg invalid object id {value}: expected a non-negative integer ID oggetto {value} non valido: è richiesto un intero non negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id la voce {index} di `{key}` è {value}, non un ID oggetto (intero non negativo) @msg no such object id {value} nessun oggetto con ID {value} +@msg id {value} is a ruler guide, not an object l’ID {value} è di una guida, non di un oggetto +@msg a ruler guide goes only directly in a layer or sublayer una guida può stare solo direttamente in un livello o sottolivello @msg id {value} is a layer, not an object l’ID {value} è un livello, non un oggetto @msg id {value} is a layer, not an object; move layers with layer.move l’ID {value} è un livello, non un oggetto; sposta i livelli con layer.move @msg automation filesystem access is not granted: read authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di lettura: {path} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index aabe534b69..fb16ab4a68 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5009,6 +5009,8 @@ swatch Bright Violet ビビッドバイオレット @msg invalid object id {value}: expected a non-negative integer オブジェクト ID {value} は正しくありません: 0 以上の整数を指定してください @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` の要素 {index} は {value} で、オブジェクト ID (0 以上の整数) ではありません @msg no such object id {value} ID が {value} のオブジェクトは見つかりません +@msg id {value} is a ruler guide, not an object ID {value} はオブジェクトではなくガイドです +@msg a ruler guide goes only directly in a layer or sublayer ガイドはレイヤーまたはサブレイヤーの直下にのみ置けます @msg id {value} is a layer, not an object ID {value} はオブジェクトではなくレイヤーです @msg id {value} is a layer, not an object; move layers with layer.move ID {value} はオブジェクトではなくレイヤーです。レイヤーは layer.move で移動してください @msg automation filesystem access is not granted: read authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 読み込み権限がありません: {path} diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index bb6a5da7d9..1750b42f18 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4599,6 +4599,8 @@ font-technology Variable ცვლადი @msg invalid object id {value}: expected a non-negative integer ობიექტის არასწორი id {value}: მოსალოდნელია არაუარყოფითი მთელი რიცხვი @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-ის ელემენტი {index} არის {value} და არა ობიექტის id (არაუარყოფითი მთელი რიცხვი) @msg no such object id {value} ობიექტი id-ით {value} არ არსებობს +@msg id {value} is a ruler guide, not an object id {value} დამხმარე ხაზისაა და არა ობიექტის +@msg a ruler guide goes only directly in a layer or sublayer დამხმარე ხაზი შეიძლება იყოს მხოლოდ უშუალოდ შრეში ან ქვეშრეში @msg id {value} is a layer, not an object id {value} შრეა და არა ობიექტი @msg id {value} is a layer, not an object; move layers with layer.move id {value} შრეა და არა ობიექტი; შრეების გადასატანად გამოიყენეთ layer.move @msg automation filesystem access is not granted: read authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: წაკითხვის უფლება არ არის: {path} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b7f5824635..0218b8cdb1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4936,6 +4936,8 @@ theme Appearance 외관 @msg invalid object id {value}: expected a non-negative integer 개체 id {value}이(가) 올바르지 않습니다: 음이 아닌 정수여야 합니다 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`의 항목 {index}은(는) {value}이며, 음이 아닌 정수인 개체 id가 아닙니다 @msg no such object id {value} id가 {value}인 개체가 없습니다 +@msg id {value} is a ruler guide, not an object id {value}은(는) 개체가 아니라 안내선입니다 +@msg a ruler guide goes only directly in a layer or sublayer 안내선은 레이어나 하위 레이어 바로 아래에만 둘 수 있습니다 @msg id {value} is a layer, not an object id {value}은(는) 개체가 아닌 레이어입니다 @msg id {value} is a layer, not an object; move layers with layer.move id {value}은(는) 개체가 아닌 레이어입니다. 레이어는 layer.move로 이동하세요 @msg automation filesystem access is not granted: read authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 읽기 권한이 없습니다: {path} diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 32db6785bb..b2a76d374e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4814,6 +4814,8 @@ imageTrace Outline Contour @msg invalid object id {value}: expected a non-negative integer ongeldige object-id {value}: een niet-negatief geheel getal verwacht @msg `{key}` entry {index} is {value}, not a non-negative integer object id item {index} van `{key}` is {value}, geen object-id (een niet-negatief geheel getal) @msg no such object id {value} er is geen object met id {value} +@msg id {value} is a ruler guide, not an object id {value} is van een hulplijn, niet van een object +@msg a ruler guide goes only directly in a layer or sublayer een hulplijn kan alleen direct in een laag of sublaag staan @msg automation filesystem access is not granted: read authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de leesbevoegdheid ontbreekt: {path} @msg automation filesystem access is not granted: write authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de schrijfbevoegdheid ontbreekt: {path} @msg automation path rejected: outside the read root {root}: {path} automatiseringspad afgewezen: buiten de leesroot {root}: {path} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 398adf5f50..ab5f88106d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5033,6 +5033,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer nieprawidłowy identyfikator obiektu {value}: oczekiwano nieujemnej liczby całkowitej @msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} w `{key}` to {value}, a nie identyfikator obiektu (nieujemna liczba całkowita) @msg no such object id {value} brak obiektu o identyfikatorze {value} +@msg id {value} is a ruler guide, not an object identyfikator {value} należy do linii pomocniczej, a nie do obiektu +@msg a ruler guide goes only directly in a layer or sublayer linia pomocnicza może się znajdować tylko bezpośrednio w warstwie lub podwarstwie @msg id {value} is a layer, not an object identyfikator {value} oznacza warstwę, a nie obiekt @msg id {value} is a layer, not an object; move layers with layer.move identyfikator {value} oznacza warstwę, a nie obiekt; warstwy przenosi się za pomocą layer.move @msg automation filesystem access is not granted: read authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do odczytu: {path} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9ede5521a8..8cbe52e076 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4433,6 +4433,8 @@ axis Both Ambos @msg invalid object id {value}: expected a non-negative integer id de objeto inválido {value}: era esperado um inteiro não negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id a entrada {index} de `{key}` é {value}, não um id de objeto (inteiro não negativo) @msg no such object id {value} não existe objeto com o id {value} +@msg id {value} is a ruler guide, not an object o id {value} é de uma guia, não de um objeto +@msg a ruler guide goes only directly in a layer or sublayer uma guia só pode ficar diretamente em uma camada ou subcamada @msg id {value} is a layer, not an object o id {value} é uma camada, não um objeto @msg id {value} is a layer, not an object; move layers with layer.move o id {value} é uma camada, não um objeto; mova camadas com layer.move @msg automation filesystem access is not granted: read authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de leitura: {path} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 50eb9e969d..c25d7022b8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4964,6 +4964,8 @@ axis Both Оба @msg invalid object id {value}: expected a non-negative integer недопустимый идентификатор объекта {value}: ожидается неотрицательное целое число @msg `{key}` entry {index} is {value}, not a non-negative integer object id элемент {index} в `{key}` равен {value}, а не идентификатору объекта (неотрицательному целому числу) @msg no such object id {value} объект с идентификатором {value} не существует +@msg id {value} is a ruler guide, not an object идентификатор {value} принадлежит направляющей, а не объекту +@msg a ruler guide goes only directly in a layer or sublayer направляющая может находиться только непосредственно в слое или подслое @msg id {value} is a layer, not an object идентификатор {value} относится к слою, а не к объекту @msg id {value} is a layer, not an object; move layers with layer.move идентификатор {value} относится к слою, а не к объекту; перемещайте слои с помощью layer.move @msg automation filesystem access is not granted: read authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на чтение: {path} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 60324bb91e..8ebb74081c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4841,6 +4841,8 @@ axis Both Båda @msg invalid object id {value}: expected a non-negative integer ogiltigt objekt-id {value}: ett icke-negativt heltal förväntades @msg `{key}` entry {index} is {value}, not a non-negative integer object id post {index} i `{key}` är {value}, inte ett objekt-id (ett icke-negativt heltal) @msg no such object id {value} det finns inget objekt med id {value} +@msg id {value} is a ruler guide, not an object id {value} tillhör en stödlinje, inte ett objekt +@msg a ruler guide goes only directly in a layer or sublayer en stödlinje kan bara ligga direkt i ett lager eller underlager @msg id {value} is a layer, not an object id {value} är ett lager, inte ett objekt @msg id {value} is a layer, not an object; move layers with layer.move id {value} är ett lager, inte ett objekt; flytta lager med layer.move @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringen har inte fått åtkomst till filsystemet: läsbehörighet saknas: {path} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 496dba8cf5..7bbef2f652 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5672,6 +5672,8 @@ theme Appearance Görünüm @msg invalid object id {value}: expected a non-negative integer geçersiz nesne kimliği {value}: negatif olmayan bir tam sayı bekleniyordu @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` içindeki {index} dizinli öğe {value}, negatif olmayan tam sayı bir nesne kimliği değil @msg no such object id {value} böyle bir nesne kimliği yok: {value} +@msg id {value} is a ruler guide, not an object bu kimlik bir nesneye değil, bir kılavuza ait: {value} +@msg a ruler guide goes only directly in a layer or sublayer bir kılavuz yalnızca doğrudan bir katmanda veya alt katmanda bulunabilir @msg id {value} is a layer, not an object {value} kimliği bir katmana ait, bir nesneye değil @msg id {value} is a layer, not an object; move layers with layer.move {value} kimliği bir katmana ait, bir nesneye değil; katmanları layer.move ile taşıyın @msg {key} must be Unix seconds (an integer) or null {key}, Unix saniyesi (bir tam sayı) veya null olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c1b1c3bdaf..73142d8947 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4952,6 +4952,8 @@ axis Both Обидві @msg invalid object id {value}: expected a non-negative integer неприпустимий ідентифікатор об’єкта {value}: очікується невід’ємне ціле число @msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} у `{key}` дорівнює {value}, а не ідентифікатору об’єкта (невід’ємному цілому числу) @msg no such object id {value} об’єкт з ідентифікатором {value} не існує +@msg id {value} is a ruler guide, not an object ідентифікатор {value} належить напрямній, а не об’єкту +@msg a ruler guide goes only directly in a layer or sublayer напрямна може бути лише безпосередньо в шарі або підшарі @msg id {value} is a layer, not an object ідентифікатор {value} належить шару, а не об’єкту @msg id {value} is a layer, not an object; move layers with layer.move ідентифікатор {value} належить шару, а не об’єкту; переміщуйте шари за допомогою layer.move @msg automation filesystem access is not granted: read authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на читання: {path} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d458c348ee..f4b8829314 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4877,6 +4877,8 @@ theme Appearance Giao diện @msg invalid object id {value}: expected a non-negative integer id đối tượng {value} không hợp lệ: cần một số nguyên không âm @msg `{key}` entry {index} is {value}, not a non-negative integer object id mục {index} của `{key}` là {value}, không phải id đối tượng (số nguyên không âm) @msg no such object id {value} không có đối tượng nào có id {value} +@msg id {value} is a ruler guide, not an object id {value} là của đường căn, không phải của đối tượng +@msg a ruler guide goes only directly in a layer or sublayer đường căn chỉ có thể nằm trực tiếp trong lớp hoặc lớp con @msg id {value} is a layer, not an object id {value} là một lớp, không phải đối tượng @msg id {value} is a layer, not an object; move layers with layer.move id {value} là một lớp, không phải đối tượng; hãy di chuyển lớp bằng layer.move @msg object {0} cannot have children đối tượng {0} không thể có phần tử con diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 028bd60571..134347d899 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4713,6 +4713,8 @@ imageTrace Outline 轮廓 @msg invalid object id {value}: expected a non-negative integer 无效的对象 id {value}:应为非负整数 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 处的项为 {value},不是非负整数对象 id @msg no such object id {value} 没有 id 为 {value} 的对象 +@msg id {value} is a ruler guide, not an object id {value} 是参考线,不是对象 +@msg a ruler guide goes only directly in a layer or sublayer 参考线只能直接位于图层或子图层中 @msg id {value} is a layer, not an object id {value} 是图层,不是对象 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是图层,不是对象;请使用 layer.move 移动图层 @msg automation filesystem access is not granted: read authority is absent: {path} 未授予自动化文件系统访问权限:缺少读取权限:{path} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7cb218f99f..5378031708 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5558,6 +5558,8 @@ lighting Light 光源 @msg invalid object id {value}: expected a non-negative integer 無效的物件 id {value}:應為非負整數 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 處的項目為 {value},不是非負整數物件 id @msg no such object id {value} 沒有 id 為 {value} 的物件 +@msg id {value} is a ruler guide, not an object id {value} 是參考線,不是物件 +@msg a ruler guide goes only directly in a layer or sublayer 參考線只能直接位於圖層或子圖層中 @msg id {value} is a layer, not an object id {value} 是圖層,不是物件 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是圖層,不是物件;請使用 layer.move 移動圖層 Move Anchor Point 移動錨點 diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 0c322f0a05..e1076145d3 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -210,7 +210,9 @@ fn has_key(app: &VectorcraftApp) -> bool { /// The first selected ruler guide while objects are selected too: the one Align to Key Object /// makes the key when there is no other object to be it. fn guide_for_key(app: &VectorcraftApp) -> Option { - app.session.active().filter(|st| !st.selection.is_empty()).and_then(|st| st.selection.guides.first().copied()) + let st = app.session.active().filter(|st| !st.selection.is_empty())?; + let first = st.selection.guides.first().copied()?; + vectorcraft_engine::cmd::guide_indexes(&st.doc, &[first]).first().copied() } /// The number of selected ruler guides, which Distribute spaces with the objects (#1597). diff --git a/crates/ui-egui/src/panels/align/tests.rs b/crates/ui-egui/src/panels/align/tests.rs index 76b7e3b60d..6cd9364cda 100644 --- a/crates/ui-egui/src/panels/align/tests.rs +++ b/crates/ui-egui/src/panels/align/tests.rs @@ -28,11 +28,13 @@ fn align_to_key_object_makes_a_selected_guide_the_key() { let ctx = egui::Context::default(); let ([_, key, _], left) = panel_buttons(&mut app, &ctx); click(&ctx, &mut app, key.center(), show); - assert_eq!(app.session.active().unwrap().selection.key_guide, Some(0), "{}", app.ui.status); + let st = app.session.active().unwrap(); + let key = st.selection.key_guide.map(|k| vectorcraft_engine::cmd::guide_indexes(&st.doc, &[k])); + assert_eq!(key, Some(vec![0]), "{}", app.ui.status); assert_eq!(align_to(&app), AlignTo::Key); click(&ctx, &mut app, left.center(), show); assert_eq!(left_edge(&app, &a), 120.0, "on the guide, not the artboard: {}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 120.0); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 120.0); } /// A Shift-click that leaves the key object selected on its own lets the key go, and the align @@ -532,13 +534,13 @@ fn the_distribute_buttons_send_the_spacing_with_a_key_guide() { app.ui.align_spacing = 15.0; click(&ctx, &mut app, left.center(), show); assert_eq!(lefts(&app), [70.0, 85.0, 115.0], "15 pt apart, outward from the guide: {}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 100.0, "the guide stays put"); // Horizontal Distribute Space with 15: the object before the guide ends 15 pt before it, // the one after it starts 15 pt after it. click(&ctx, &mut app, space.center(), show); assert_eq!(sent(&app), ("object.distributeSpacing".to_string(), json!("key"), Some(json!(15.0)))); assert_eq!(lefts(&app), [10.0, 45.0, 115.0], "{}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 100.0, "the guide stays put"); } /// Three vertical ruler guides selected without objects: Align Objects buttons are on, and @@ -560,7 +562,7 @@ fn the_align_buttons_align_guides_selected_alone_to_artboard() { row.sort_by(|a, b| a.left().total_cmp(&b.left())); assert!(row[0].width() > 0.0, "Horizontal Align Left is enabled"); click(&ctx, &mut app, row[0].center(), show); - let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); assert_eq!(pos(&app), [0.0, 0.0, 0.0], "{}", app.ui.status); } @@ -585,7 +587,7 @@ fn the_distribute_buttons_space_guides_selected_alone() { assert_eq!(row.len(), 6, "the Distribute row"); // Its fifth button is Horizontal Distribute Center. click(&ctx, &mut app, row[4].center(), show); - let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); assert_eq!(pos(&app), [20.0, 100.0, 180.0], "{}", app.ui.status); app.run("edit.undo", json!({})).unwrap(); assert_eq!(pos(&app), [20.0, 50.0, 180.0], "one undo step"); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 313242bb48..f027f2c717 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -151,8 +151,10 @@ struct View<'a> { /// Where the row thumbnails come from. thumbs: ThumbSource<'a>, sel: HashSet, - /// The selected ruler guides. + /// The selected ruler guides, as indexes into [`Self::ruler_guides`]. guides: Vec, + /// The document's ruler guides ([`Document::ruler_guides`]). + ruler_guides: Vec, target: Option, current: Option, rows: Vec, @@ -262,7 +264,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { doc: &doc, thumbs: ThumbSource::of(app, st, &doc), sel: st.selection.objects.iter().copied().collect(), - guides: st.selection.guides.clone(), + guides: vectorcraft_engine::cmd::guide_indexes(&doc, &st.selection.guides), + ruler_guides: doc.ruler_guides(), target: st.selection.target, current: st.current_layer(), rows, @@ -468,7 +471,8 @@ fn listed_when_opened(doc: &Document, open: &OpenRows, opts: &PanelOptions, quer open.set(*a, true); } let mut rows = vec![]; - push_rows(layer, 0, false, (Color32::PLACEHOLDER, true), (&open, opts, query, filter, doc), &mut rows); + // Only object rows are looked for, so the guide rows are left out. + push_rows(layer, 0, false, (Color32::PLACEHOLDER, true), (&open, opts, query, filter, &[]), &mut rows); rows.iter().any(|r| r.node.id == id && r.guide.is_none() && !r.spine) } diff --git a/crates/ui-egui/src/panels/layers/model.rs b/crates/ui-egui/src/panels/layers/model.rs index bbafbb7559..c24863ffbc 100644 --- a/crates/ui-egui/src/panels/layers/model.rs +++ b/crates/ui-egui/src/panels/layers/model.rs @@ -1,5 +1,5 @@ use super::rows::key; -use super::{Arc, Color32, Document, LiveShape, Node, NodeKind, OpenRows, PanelOptions, Ui, Value, VectorcraftApp}; +use super::{Arc, Color32, Document, LiveShape, Node, NodeId, NodeKind, OpenRows, PanelOptions, Ui, Value, VectorcraftApp}; /// Where a dragged row goes relative to the row it is dropped on. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -42,7 +42,8 @@ pub(crate) fn shown_name(app: &VectorcraftApp, n: &Node, lang: crate::i18n::Lang pub(super) fn opens(n: &Node, opts: &PanelOptions) -> bool { match n.kind { NodeKind::Compound { .. } => false, - _ => n.children().is_some_and(|c| c.iter().any(|c| !opts.layers_only || c.is_layer())), + // Ruler guide nodes are not object rows (a layer's guide rows are counted by its caller). + _ => n.children().is_some_and(|c| c.iter().any(|c| !c.is_ruler_guide() && (!opts.layers_only || c.is_layer()))), } } @@ -226,25 +227,29 @@ struct RowsKey { /// contains `query` (lowercase) and that `filter` keeps, and every row holding one. pub(super) fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str, filter: &Filter) -> Vec { let mask_layer = doc.mask_edit.map(|m| m.layer); + // The ruler guides' ids by `guide.list` index, listed once for every layer. + let guides: Vec = doc.ruler_guides().into_iter().filter_map(|g| g.id).collect(); let mut out = vec![]; for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { - push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, filter, doc), &mut out); + push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, filter, &guides), &mut out); } out } /// Add the rows of `n` and those open inside it to `out`; `(colour, shown)` are those of what holds -/// it (the layer's colour, and whether it is visible). +/// it (the layer's colour, and whether it is visible). The last item of `cx` is the document's +/// ruler guide ids by `guide.list` index. pub(super) fn push_rows( n: &Arc, depth: usize, clip_path: bool, (colour, shown): (Color32, bool), - cx: (&OpenRows, &PanelOptions, &str, &Filter, &Document), + cx: (&OpenRows, &PanelOptions, &str, &Filter, &[NodeId]), out: &mut Vec, ) { - let (open_rows, opts, query, filter, doc) = cx; - if opts.layers_only && !n.is_layer() { + let (open_rows, opts, query, filter, all_guides) = cx; + // A ruler guide node gets no object row: its layer lists the guide rows. + if n.is_ruler_guide() || (opts.layers_only && !n.is_layer()) { return; } let colour = match &n.kind { @@ -257,10 +262,16 @@ pub(super) fn push_rows( let shown = shown && n.visible; let searching = !query.is_empty(); let narrowing = searching || filter.is_on(); - // A layer's ruler guides are rows in it, above its art (not while searching, nor while the - // filter leaves them out). - let guides: Vec = - if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] }; + // A layer's ruler guides are rows in it, above its art, the highest `guide.list` index first + // (not while searching, nor while the filter leaves them out). + let guides: Vec = if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { + let mut found: Vec = + n.children().into_iter().flatten().filter(|c| c.is_ruler_guide()).filter_map(|c| all_guides.binary_search(&c.id).ok()).collect(); + found.sort_unstable(); + found + } else { + vec![] + }; let opens = opens(n, opts) || !guides.is_empty(); let open = opens && (narrowing || open_rows.contains(n.id)); let at = out.len(); diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 376c021c41..13b6ff1e2b 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -122,7 +122,7 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { let (t, h) = (&view.t, view.h); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); - let Some(g) = view.doc.guides.get(i) else { return }; + let Some(g) = view.ruler_guides.get(i) else { return }; let selected = view.guides.contains(&i); let editable = view.doc.guide_editable(g); if editable && resp.hovered() { diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index cad2530e5f..2127789577 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -650,14 +650,17 @@ fn guide_rows_select_and_move_their_guides() { let (circles, _) = frame(&mut app, &ctx, vec![], false); assert_eq!(circles.len(), 3); let guide_row = egui::pos2(150.0, (circles[1].y + circles[2].y) / 2.0); - let guides = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone(); + let guides = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides) + }; click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); assert_eq!(guides(&app), [0], "its row selects it"); // Dragged onto Layer 2's row, it goes on Layer 2, in one undo step. let undo = app.session.active().unwrap().history.undo.len(); drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[0].y), false); let st = app.session.active().unwrap(); - assert_eq!((st.doc.guides[0].layer, st.history.undo.len()), (Some(second), undo + 1)); + assert_eq!((st.doc.ruler_guides()[0].layer, st.history.undo.len()), (Some(second), undo + 1)); // Now under Layer 2: locked, its row neither selects nor drags it. run(&mut app, "layer.setProps", json!({"ids": [second.0], "locked": true})); assert!(guides(&app).is_empty(), "deselected with its layer locked"); @@ -666,7 +669,7 @@ fn guide_rows_select_and_move_their_guides() { click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); assert!(guides(&app).is_empty()); drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[2].y), false); - assert_eq!(app.session.active().unwrap().doc.guides[0].layer, Some(second)); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].layer, Some(second)); } /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows @@ -1783,3 +1786,48 @@ fn a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend() { click_with(&mut app, &ctx, spine_row, egui::Modifiers::NONE); assert!(selected(&app).is_empty(), "a locked blend's spine can't be selected"); } + +/// A ruler guide node gets no object row, and a layer that holds only one has no rows to open. +#[test] +fn ruler_guide_nodes_get_no_object_row() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let rect = NodeId(app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap()); + expand(&mut app, &json!({})).unwrap(); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let (first, second) = (doc.layers[0].id, doc.add_layer(Some("Guides only"))); + for l in [first, second] { + let g = Node::new(doc.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 20.0, artboard: None }); + doc.insert(Some(l), usize::MAX, g).unwrap(); + } + let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()); + let ids: Vec = rows.iter().filter(|r| r.guide.is_none()).map(|r| r.node.id).collect(); + assert_eq!(ids, [second, first, rect]); + // Each guide is a guide row of its layer instead. + let guide_rows: Vec = rows.iter().filter(|r| r.guide.is_some()).map(|r| r.node.id).collect(); + assert_eq!(guide_rows, [first], "the open layer lists its guide"); + assert!(rows[0].opens, "a layer with only a ruler guide opens to its guide row"); +} + +/// A layer lists its guide rows by `guide.list` index, the highest first, whatever the order of +/// the guide nodes in the layer (`guide.setLayer` puts a moved guide at the top of its new layer). +#[test] +fn guide_rows_list_by_index_whatever_the_node_order() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + for pos in [10, 20, 30] { + app.session.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let layer = doc.layers[0].id; + // The guide of index 0 moves to the top of the layer's guides. + let first = doc.ruler_guides()[0].id.unwrap(); + doc.move_node(first, Some(layer), usize::MAX).unwrap(); + let kids: Vec = doc.layers[0].children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(kids.last(), Some(&first)); + let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()); + let indexes: Vec = rows.iter().filter_map(|r| r.guide).collect(); + assert_eq!(indexes, [2, 1, 0]); +} diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index 2f15878133..dacf77425c 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -230,7 +230,8 @@ fn weigh(n: &Node, nodes: usize, anchors: usize, masks: bool, doc: Option<&Docum return false; } let mask = n.extras().mask.as_deref().filter(|m| left.masks && !m.disabled).map(|m| &m.art); - n.children().into_iter().flatten().chain(mask).all(|c| left.object() && count(c, doc, left, depth)) + // A layer's ruler guides draw nothing in its thumbnail and use no budget. + n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).chain(mask).all(|c| left.object() && count(c, doc, left, depth)) } let mut left = Left { nodes, anchors, steps: THUMB_COUNT_STEPS, masks }; let small = count(n, doc, &mut left, 0); diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index ea1a60095a..a9853d3173 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1733,7 +1733,7 @@ fn selected_guides_show_their_position_in_a_transform_section() { app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let v = app.run("guide.add", json!({"vertical": true, "pos": 30})).unwrap()["index"].as_u64().unwrap(); let h = app.run("guide.add", json!({"vertical": false, "pos": 20})).unwrap()["index"].as_u64().unwrap(); - let pos = |app: &VectorcraftApp, i: u64| app.session.doc().unwrap().doc.guides[i as usize].pos; + let pos = |app: &VectorcraftApp, i: u64| app.session.doc().unwrap().doc.ruler_guides()[i as usize].pos; let ctx = egui::Context::default(); // Steps the field labelled `label` up by one with ↑. let step = |app: &mut VectorcraftApp, label: &str| { diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 2596b218b5..25ddd40764 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -223,7 +223,7 @@ pub(crate) fn selected_guides(app: &VectorcraftApp) -> Option<(Option, Opti return None; } let least = |vertical: bool| { - st.selection.guides.iter().filter_map(|i| st.doc.guides.get(*i)).filter(|g| g.vertical == vertical).map(|g| g.pos).min_by(f64::total_cmp) + st.selection.guides.iter().filter_map(|i| st.doc.ruler_guide(*i)).filter(|g| g.vertical == vertical).map(|g| g.pos).min_by(f64::total_cmp) }; Some((least(true), least(false))) } diff --git a/crates/ui-egui/src/tests/guide_escape.rs b/crates/ui-egui/src/tests/guide_escape.rs index 90c77f47dd..113d2b1e2f 100644 --- a/crates/ui-egui/src/tests/guide_escape.rs +++ b/crates/ui-egui/src/tests/guide_escape.rs @@ -90,7 +90,7 @@ impl GuideDrag { let point = |at| if vertical { Point::new(at, 60.0) } else { Point::new(60.0, at) }; (f.from, f.to, f.end) = (xf.to_screen(point(100.0)), xf.to_screen(point(140.0)), xf.to_screen(point(160.0))); assert!([f.from, f.to, f.end].into_iter().all(|p| rect.contains(p))); - assert_eq!(f.selection.guides, vec![0, 1]); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&f.before, &f.selection.guides), vec![0, 1]); f.app.session.active_mut().unwrap().mark_saved(); f.unchanged(); f @@ -140,17 +140,30 @@ impl GuideDrag { } fn guides_at_target(&self) { - let mut moved = self.before.guides.clone(); + let line = |g: &vectorcraft_doc::Guide| (g.vertical, g.pos, g.artboard, g.layer); + let before: Vec<_> = self.before.ruler_guides().iter().map(line).collect(); + let mut moved = before.clone(); for guide in &mut moved { - if guide.vertical == self.vertical { - guide.pos += 40.0; + if guide.0 == self.vertical { + guide.1 += 40.0; } } - let expected = if self.copy { self.before.guides.iter().cloned().chain(moved).collect::>() } else { moved }; + let expected = if self.copy { before.into_iter().chain(moved).collect::>() } else { moved }; let st = self.app.session.active().unwrap(); - assert_eq!(st.doc.guides, expected); - assert_eq!(st.doc.layers, self.before.layers); - assert_eq!(st.selection.guides, if self.copy { vec![2, 3] } else { vec![0, 1] }); + assert_eq!(st.doc.ruler_guides().iter().map(line).collect::>(), expected); + let art = |d: &vectorcraft_doc::Document| { + d.layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect::>() + }; + assert_eq!(art(&st.doc), art(&self.before)); + let selected = vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides); + assert_eq!(selected, if self.copy { vec![2, 3] } else { vec![0, 1] }); } fn unchanged(&self) { diff --git a/crates/ui-egui/src/tests/ruler_origin.rs b/crates/ui-egui/src/tests/ruler_origin.rs index de4a0501e6..365cc92ef7 100644 --- a/crates/ui-egui/src/tests/ruler_origin.rs +++ b/crates/ui-egui/src/tests/ruler_origin.rs @@ -80,7 +80,7 @@ fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() assert!(st.doc.ruler_origin_for(0).distance(goal) < 1e-4, "{:?}", st.doc.ruler_origin_for(0)); assert_eq!(st.history.undo.len(), undo + 1); assert_eq!(st.doc.art_bounds(), None, "the drawing tool never receives the corner drag"); - assert!(st.doc.guides.is_empty()); + assert!(st.doc.ruler_guides().is_empty()); let origin = st.doc.ruler_origin_for(0); app.run("edit.undo", json!({})).unwrap(); assert_eq!(&*app.session.doc().unwrap().doc, &*before); diff --git a/crates/ui-egui/src/tests/sysclip.rs b/crates/ui-egui/src/tests/sysclip.rs index 595d12b51f..1da5b93322 100644 --- a/crates/ui-egui/src/tests/sysclip.rs +++ b/crates/ui-egui/src/tests/sysclip.rs @@ -174,8 +174,9 @@ pub(super) fn pasted(app: &VectorcraftApp) -> Vec { st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect() } +/// The objects on the first layer (its ruler guides left out). pub(super) fn count(app: &VectorcraftApp) -> usize { - app.session.active().unwrap().doc.layers[0].children().unwrap().len() + app.session.active().unwrap().doc.layers[0].children().unwrap().iter().filter(|c| !c.is_ruler_guide()).count() } /// An egui `PasteImage` from the host is the signal to read the rich system clipboard: it must diff --git a/crates/ui-egui/src/tests/sysclip_guides.rs b/crates/ui-egui/src/tests/sysclip_guides.rs index a54bd57eb6..50c4535c37 100644 --- a/crates/ui-egui/src/tests/sysclip_guides.rs +++ b/crates/ui-egui/src/tests/sysclip_guides.rs @@ -14,7 +14,7 @@ use crate::tests_sysclip::{app, app_with_busy_worker, copy_elsewhere, copy_rect, use crate::{Services, VectorcraftApp, background}; fn guides(app: &VectorcraftApp) -> usize { - app.session.active().unwrap().doc.guides.len() + app.session.active().unwrap().doc.ruler_guides().len() } /// Add a vertical guide at `pos`, select only it and copy it. @@ -35,7 +35,8 @@ fn a_guides_only_copy_pastes_the_guides_not_what_another_app_copied_before() { run(&mut app, "edit.paste", json!({})); assert_eq!(guides(&app), 2, "the copied guide was pasted"); assert_eq!(count(&app), 0, "and not the other app's text"); - assert_eq!(app.session.active().unwrap().selection.guides, [1]); + let st = app.session.active().unwrap(); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), [1]); // A second Paste takes the guides again. run(&mut app, "edit.paste", json!({})); assert_eq!((guides(&app), count(&app)), (3, 0)); diff --git a/docs/mcp.md b/docs/mcp.md index c766d812a1..6bf426fa41 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2212,7 +2212,11 @@ kept with the workspace, and built-in workspaces and workspaces saved without it ## Ruler guides -Ruler guides are numbered in the order they were made. `guide.add {vertical, pos, artboard?}` makes one (the x of a +Each ruler guide is an object of a layer or sublayer, directly in it and above its art, and takes an id from the +document's object ids. Ruler guides are numbered by id, which is the order they were made (a guide read from an `.ai` +file takes its place in the file). An object command given a ruler guide's id (in `ids` or `id`) fails with an error, +and `node.move` cannot put a ruler guide into a group or out of its layers; the guide commands below take guides by +number. `guide.add {vertical, pos, artboard?}` makes one (the x of a vertical guide, the y of a horizontal one, in points) → `{index}`. With `artboard` (an artboard's index) it is an artboard guide: it runs across that artboard only, moves with it (`artboard.move`, a pure move in `artboard.setProps`, `artboard.rearrange`), scales with it (`artboard.setProps {scaleArt}`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is @@ -2236,8 +2240,8 @@ with guides unlocked, as the preference is off until `prefs.set` turns it on. `view.guides.release {index?, ids?}` (View › Guides › Release Guides) turns guides into paths → `{count}`. With `index` it releases that ruler guide, and with `ids` it releases those guide paths (and the guide paths inside them). Otherwise it releases the selected guide paths and ruler guides, and with no guide selected it releases every guide -path. A released ruler guide becomes a straight path with no fill or stroke, at the top of the guide's layer (or where -new art goes, for a guide on no layer). An artboard guide becomes a line across its artboard, and a canvas guide +path. A released ruler guide becomes a straight path with no fill or stroke, at the top of the art of the guide's +layer. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the bounds of all the artboards. `index` and `ids` are refused while guides are locked. The release is one undo step, and the released paths are selected. With the Selection, Direct Selection or Group Selection tool, a double-click with Cmd/Ctrl and Shift held releases the ruler guide or guide path under the pointer From 3ef3afc51d761477664c4667e4ac120eebcf8d46 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 12:53:09 -0500 Subject: [PATCH 44/49] Updates: take craft-update from craft-libs main at 6e03f65bf --- Cargo.lock | 5 +++-- Cargo.toml | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74b6189119..064b89e9e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1070,7 +1070,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "chrono", "dunce", @@ -1080,6 +1080,7 @@ dependencies = [ "image", "log", "plist", + "rustix 1.1.6", "semver", "serde", "serde_json", @@ -1096,7 +1097,7 @@ dependencies = [ [[package]] name = "craft-vectorcraft-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "flate2", "serde", diff --git a/Cargo.toml b/Cargo.toml index 2d53acdc39..6d47f057c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,9 +21,9 @@ craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637 # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] } # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. -craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" } +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } From 482511390647ddc8e7319700cd01f9345d6019f5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 09:27:45 -0800 Subject: [PATCH 45/49] Part of #6622: Help Bar hints and tool names show in full on hover when clipped The status message already ends in an ellipsis and shows its full text on hover when it is clipped. The Help Bar's hint and the tool name in the status bar ran past the window edge instead; the Dimension tool's hint is wider than an 800-point window. Both now end in an ellipsis and show their full text in a tooltip on hover. Screen readers receive the full text, and text that fits shows no tooltip. In a narrow status bar the tool name now ends in an ellipsis before it takes the status message's room. Before, the tool name could leave the message almost no width. The tooltips of the clipped hint, tool name and message wrap to fit inside the window. egui's tooltips are 500 points wide, which is wider than an 800-point window at UI Scaling 1.6 or more. The hint and tool name tests fail without the change. The status message test covers its existing ellipsis and tooltip. --- crates/ui-egui/src/chrome/hints.rs | 4 +- crates/ui-egui/src/chrome/status_bar.rs | 40 ++++++- crates/ui-egui/src/chrome/tests.rs | 138 +++++++++++++++++++++--- docs/target-app-parity.md | 1 + 4 files changed, 162 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/chrome/hints.rs b/crates/ui-egui/src/chrome/hints.rs index 062059e6d7..02f34438a6 100644 --- a/crates/ui-egui/src/chrome/hints.rs +++ b/crates/ui-egui/src/chrome/hints.rs @@ -1,4 +1,4 @@ -use super::status_bar::ANIMATED_ZOOM_HINT; +use super::status_bar::{ANIMATED_ZOOM_HINT, clipped_label}; use super::{Cow, Sense, Stroke, Tokens, Ui, VectorcraftApp, menus, theme, vec2, widgets}; fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { @@ -249,7 +249,7 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() }); } - ui.label(job); + clipped_label(ui, job); }); }); widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 8995080215..1ed8df8739 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -80,13 +80,48 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown } }) .unwrap_or(""); - ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim)); + // The tool name leaves the status message its least width and ends in an ellipsis first. + let room = (ui.available_width() - MESSAGE_MIN - ui.spacing().item_spacing.x).max(0.0); + ui.scope(|ui| { + ui.set_max_width(room); + clipped_label(ui, egui::RichText::new(tool).size(11.5).color(t.text_dim)); + }); status_readouts(app, ui, &t); }); }); widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); } +/// The least width the status message keeps when the window is narrow. +const MESSAGE_MIN: f32 = 180.0; + +/// A label that ends in an ellipsis when it is wider than the room left in `ui`. Only a cut label +/// shows its full text in a tooltip; screen readers always receive the full text. +pub(super) fn clipped_label(ui: &mut Ui, text: impl Into) -> egui::Response { + let galley = text.into().into_galley(ui, Some(egui::TextWrapMode::Truncate), ui.available_width(), egui::FontSelection::Default); + let full = galley.elided.then(|| egui::text::LayoutJob { + sections: galley.job.sections.clone(), + text: galley.job.text.clone(), + language: galley.job.language.clone(), + ..Default::default() + }); + let response = ui.add(egui::Label::new(galley).show_tooltip_when_elided(false)); + match full { + Some(job) => full_text_tooltip(response, job), + None => response, + } +} + +/// Show `text` on hover in a tooltip that wraps to fit inside the window. +fn full_text_tooltip(response: egui::Response, text: impl Into) -> egui::Response { + let text = text.into(); + response.on_hover_ui(|ui| { + let margins = ui.spacing().menu_margin.sum().x + 2.0 * ui.spacing().item_spacing.x; + ui.set_max_width(ui.spacing().tooltip_width.min(ui.ctx().content_rect().width() - margins).max(0.0)); + ui.add(egui::Label::new(text).wrap()); + }) +} + /// The most recently measured status widths, scoped to this UI and display settings. pub(super) struct StatusReadoutWidths { pub(super) key: (crate::i18n::Lang, vectorcraft_doc::Unit, f32), @@ -125,7 +160,6 @@ fn status_readout_widths(ui: &Ui, lang: crate::i18n::Lang, unit: vectorcraft_doc /// Allocate the remaining row once, then paint each field inside its own bounded rectangle. /// Child UIs requested with a desired width can shrink to their contents; these rectangles cannot. pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { - const MESSAGE_MIN: f32 = 180.0; let mono = theme::mono(10.5); let proportional = egui::FontId::proportional(10.5); let unit = app.session.general_unit(); @@ -229,7 +263,7 @@ fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egu let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover()); response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); if elided { - response.on_hover_text(text); + full_text_tooltip(response, text); } } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 452fec1cc0..5966a3e98a 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -163,22 +163,7 @@ fn status_readout_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, wi /// Collect status-bar text, positions, clips and elision state with explicit pointer events. fn status_readout_frame_events(app: &mut crate::VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec) -> Vec { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Text(t) => Some(StatusText { - text: t.galley.job.text.clone(), - at: t.pos, - clip: s.clip_rect, - bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), - elided: t.galley.elided, - }), - _ => None, - }) - .collect() + bar_frame(app, ctx, width, None, events, super::status_bar).0 } /// Find a required text record by prefix, failing the test when it is absent. @@ -373,6 +358,127 @@ fn narrow_status_bars_preserve_the_message_and_hide_optional_readouts() { assert!(message.clip.left() >= status_text(&texts, "Selection").bounds.right()); } +/// A function that draws one of the bars at the bottom of the window. +type DrawBar = fn(&mut crate::VectorcraftApp, &mut egui::Ui); + +/// The texts (positions, clips and elision state) and the screen-reader tree of one frame of +/// `draw` in a window `width` points wide, at `time` with `events`. +fn bar_frame( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + width: f32, + time: Option, + events: Vec, + draw: DrawBar, +) -> (Vec, Vec) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); + let input = egui::RawInput { screen_rect: Some(screen), time, events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| draw(app, ui)); + out.textures_delta.clear(); + let texts = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(StatusText { + text: t.galley.job.text.clone(), + at: t.pos, + clip: s.clip_rect, + bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), + elided: t.galley.elided, + }), + _ => None, + }) + .collect(); + let nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + (texts, nodes) +} + +/// The texts of `draw` with the pointer away, the screen-reader tree, and the texts after the +/// pointer has rested on the first text that starts with `prefix` for longer than the tooltip delay. +fn bar_hover( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + width: f32, + prefix: &str, + draw: DrawBar, +) -> (Vec, Vec, Vec) { + let t = ctx.input(|i| i.time) + 1.0; + bar_frame(app, ctx, width, Some(t), vec![egui::Event::PointerGone], draw); + let (texts, nodes) = bar_frame(app, ctx, width, Some(t + 0.1), vec![], draw); + let at = status_text(&texts, prefix); + let pos = at.clip.intersect(at.bounds).center(); + bar_frame(app, ctx, width, Some(t + 1.0), vec![egui::Event::PointerMoved(pos)], draw); + bar_frame(app, ctx, width, Some(t + 3.0), vec![], draw); + let (hovered, _) = bar_frame(app, ctx, width, Some(t + 3.1), vec![], draw); + (texts, nodes, hovered) +} + +/// Checks the field whose full text is `full`: in a window `narrow` wide it ends in an ellipsis +/// inside the window, screen readers get the full text, and resting the pointer on it shows the +/// full text in a tooltip no wider than the window; in a window `wide` wide it shows in full with no +/// tooltip. +fn assert_clipped_text_shows_on_hover(app: &mut crate::VectorcraftApp, ctx: &egui::Context, full: &str, narrow: f32, wide: f32, draw: DrawBar) { + let prefix: String = full.chars().take(4).collect(); + let (texts, nodes, hovered) = bar_hover(app, ctx, narrow, &prefix, draw); + let field = status_text(&texts, &prefix); + assert_eq!(field.text, full); + assert!(field.elided, "{narrow}: the text is clipped: {field:?}"); + assert!(field.bounds.right() <= narrow, "{narrow}: the ellipsis is inside the window: {field:?}"); + assert!(nodes.iter().any(|n| n.value() == Some(full)), "{narrow}: screen readers receive the full text"); + let tip: Vec<_> = hovered.iter().filter(|t| t.text == full && !t.elided).collect(); + assert_eq!(tip.len(), 1, "{narrow}: the tooltip shows the full text: {hovered:?}"); + assert!(tip[0].bounds.left() >= 0.0 && tip[0].bounds.right() <= narrow, "{narrow}: the tooltip wraps within the window: {tip:?}"); + + let (texts, _, hovered) = bar_hover(app, ctx, wide, &prefix, draw); + assert!(!status_text(&texts, &prefix).elided, "{wide}: the text fits"); + assert_eq!(hovered.iter().filter(|t| t.text == full).count(), 1, "{wide}: no tooltip: {hovered:?}"); +} + +/// A status message too long for the bar ends in an ellipsis and shows in full on hover, in a +/// tooltip that wraps within the window (#6622). +#[test] +fn a_clipped_status_message_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + let long = format!("Saved recovery data for {}illustration.vectorcraft", "a-long-folder-name/".repeat(12)); + app.ui.status = long.clone(); + assert_clipped_text_shows_on_hover(&mut app, &ctx, &long, 700.0, 20000.0, super::status_bar); + app.ui.status = "Saved recovery data for Untitled-1".to_owned(); + let (texts, _, hovered) = bar_hover(&mut app, &ctx, 700.0, "Saved", super::status_bar); + assert!(!status_text(&texts, "Saved").elided); + assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Saved")).count(), 1, "a message that fits has no tooltip: {hovered:?}"); +} + +/// The Help Bar's hint ends in an ellipsis when it is wider than the window and shows in full on +/// hover, in a tooltip that wraps within the window (#6622). The Dimension tool's hint is wider +/// than the smallest main window (800 points); 400 points is that window at UI Scaling 2.0. +#[test] +fn a_clipped_help_bar_hint_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + app.select_tool("dimension"); + let hint: String = super::hint_segments(app.session.tool_id()).into_iter().map(|(s, _)| s).collect(); + for narrow in [800.0, 400.0] { + assert_clipped_text_shows_on_hover(&mut app, &ctx, &hint, narrow, 3000.0, super::hint_bar); + } +} + +/// In a narrow status bar the tool name ends in an ellipsis, shows in full on hover, and leaves +/// the status message its room (#6622). 640 and 500 points are the smallest main window (800 +/// points) at UI Scaling 1.25 and 1.6. +#[test] +fn a_clipped_tool_name_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + for narrow in [640.0, 500.0] { + assert_clipped_text_shows_on_hover(&mut app, &ctx, "Selection", narrow, 1400.0, super::status_bar); + let (texts, _) = bar_frame(&mut app, &ctx, narrow, None, vec![], super::status_bar); + let tool = status_text(&texts, "Selection"); + let message = status_text(&texts, "Saved recovery data"); + assert!(message.clip.left() >= tool.bounds.right() && message.clip.width() >= 24.0, "{narrow}: the message keeps room: {texts:?}"); + } +} + /// Busy-job transitions preserve message placement, labels and accessibility. #[test] fn background_activity_keeps_the_status_message_origin() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e5feae68d7..7aa08bf614 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -574,6 +574,7 @@ since 2026-10-01. **Review follow-up:** The fit-query follow-up covers fitting after draft dimensions change, updated artwork after a document revision, cache scope and new-artboard fitting/history. Selection uses the existing application bounds cache; global artwork queries include all drawn document art and are cached by active document and revision; draft geometry and target artboards do not limit membership. This is not a native timing claim. - **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change. +- **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. From a72412eb8f5e6041384a64158127d1dcd565dde6 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:04:49 +0100 Subject: [PATCH 46/49] Release: VectorCraft v0.24.0 (#6599) --- Cargo.lock | 60 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 064b89e9e8..b3ec5a0b91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6313,7 +6313,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.21.0" +version = "0.24.0" dependencies = [ "arboard", "block2 0.6.2", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.21.0" +version = "0.24.0" dependencies = [ "crc32fast", "flate2", @@ -6370,14 +6370,14 @@ dependencies = [ [[package]] name = "vectorcraft-apischema" -version = "0.21.0" +version = "0.24.0" dependencies = [ "serde_json", ] [[package]] name = "vectorcraft-brush" -version = "0.21.0" +version = "0.24.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.21.0" +version = "0.24.0" dependencies = [ "log", "objc2-core-foundation", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.21.0" +version = "0.24.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -6440,7 +6440,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.21.0" +version = "0.24.0" dependencies = [ "chrono", "hayro-ccitt", @@ -6459,7 +6459,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6477,7 +6477,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.21.0" +version = "0.24.0" dependencies = [ "cfb", "chrono", @@ -6530,7 +6530,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.21.0" +version = "0.24.0" dependencies = [ "craft-pdf", "encoding_rs", @@ -6558,7 +6558,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.21.0" +version = "0.24.0" dependencies = [ "craft-vectorcraft-format", "flate2", @@ -6574,7 +6574,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.21.0" +version = "0.24.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "vectorcraft-js" -version = "0.21.0" +version = "0.24.0" dependencies = [ "chrono", "proptest", @@ -6595,7 +6595,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "log", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6628,7 +6628,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.21.0" +version = "0.24.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -6640,7 +6640,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.21.0" +version = "0.24.0" dependencies = [ "aes", "chrono", @@ -6674,7 +6674,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.21.0" +version = "0.24.0" dependencies = [ "craft-vectorcraft-format", "image", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.21.0" +version = "0.24.0" dependencies = [ "flate2", "gif 0.14.2", @@ -6723,14 +6723,14 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.21.0" +version = "0.24.0" dependencies = [ "proptest", ] [[package]] name = "vectorcraft-svg" -version = "0.21.0" +version = "0.24.0" dependencies = [ "craft-docpath", "flate2", @@ -6757,7 +6757,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.21.0" +version = "0.24.0" dependencies = [ "fax", "image", @@ -6775,7 +6775,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.21.0" +version = "0.24.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -6796,7 +6796,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.21.0" +version = "0.24.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -6804,7 +6804,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6821,7 +6821,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.21.0" +version = "0.24.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6833,7 +6833,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.21.0" +version = "0.24.0" dependencies = [ "bytemuck", "craft-keymap", @@ -6871,7 +6871,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.21.0" +version = "0.24.0" dependencies = [ "craft-web-api", "eframe", @@ -8003,7 +8003,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.21.0" +version = "0.24.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 6d47f057c0..606c34f8b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.21.0" +version = "0.24.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 72c7cb5a739b32367fd05f6dd818452d856a2748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Wed, 11 Nov 2026 15:34:36 +0000 Subject: [PATCH 47/49] G5: built-in Energy Beams brush library Six brush libraries; MCP docs list Energy Beams with the other generated brush libraries. --- crates/engine/src/cmd/brushlib.rs | 30 +++++++++++++++++++ crates/engine/src/tests/brushlib.rs | 13 +++++++- crates/ui-egui/src/i18n/bg.tsv | 5 ++++ crates/ui-egui/src/i18n/bs.tsv | 5 ++++ crates/ui-egui/src/i18n/ca.tsv | 5 ++++ crates/ui-egui/src/i18n/cs.tsv | 5 ++++ crates/ui-egui/src/i18n/da.tsv | 5 ++++ crates/ui-egui/src/i18n/de.tsv | 5 ++++ crates/ui-egui/src/i18n/el.tsv | 5 ++++ crates/ui-egui/src/i18n/es.tsv | 5 ++++ crates/ui-egui/src/i18n/fi.tsv | 5 ++++ crates/ui-egui/src/i18n/fr.tsv | 5 ++++ crates/ui-egui/src/i18n/hi.tsv | 5 ++++ crates/ui-egui/src/i18n/hu.tsv | 5 ++++ crates/ui-egui/src/i18n/id.tsv | 5 ++++ crates/ui-egui/src/i18n/it.tsv | 5 ++++ crates/ui-egui/src/i18n/ja.tsv | 5 ++++ crates/ui-egui/src/i18n/ka.tsv | 5 ++++ crates/ui-egui/src/i18n/ko.tsv | 5 ++++ crates/ui-egui/src/i18n/nl.tsv | 5 ++++ crates/ui-egui/src/i18n/pl.tsv | 5 ++++ crates/ui-egui/src/i18n/pt-br.tsv | 5 ++++ crates/ui-egui/src/i18n/ru.tsv | 5 ++++ crates/ui-egui/src/i18n/sv.tsv | 5 ++++ crates/ui-egui/src/i18n/tr.tsv | 5 ++++ crates/ui-egui/src/i18n/uk.tsv | 5 ++++ crates/ui-egui/src/i18n/vi.tsv | 5 ++++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 ++++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 ++++ .../src/panels/tests_brush_libraries.rs | 12 +++++++- docs/gaps.md | 9 +++--- docs/mcp.md | 4 +-- docs/roadmap.md | 5 ++-- docs/target-app-parity.md | 11 +++---- 34 files changed, 204 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index b58af91ff8..8de9dd5c84 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -172,6 +172,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, + BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams }, ]; /// Built-in library `id`, made once. @@ -416,6 +417,35 @@ fn game_trails() -> BrushLibrary { built("Game Trails", Document::new(100.0, 100.0), brushes) } +/// Energy Beams: art brushes for laser, plasma and charge strokes (original game VFX art). +fn energy_beams() -> BrushLibrary { + let laser = { + let n = 32; + let half = |x: f64| 1.6 * (1.0 - (x / 100.0 - 0.5).abs() * 1.6).max(0.12); + let xs = |i: usize| 100.0 * i as f64 / n as f64; + let mut pts: Vec = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect(); + pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x)))); + PathData::single(SubPath::polyline(&pts, true)) + }; + let plasma = band(100.0, 2.2, 48, |x| 4.0 * (x / 100.0 * std::f64::consts::PI).sin()); + let charge = band(100.0, 1.6, 56, |x| 3.5 * (x / 100.0 * std::f64::consts::TAU).sin()); + let spark = group(vec![ + black(shapes::rounded_rectangle(Rect::new(0.0, -1.2, 10.0, 1.2), 0.8)), + black(shapes::rounded_rectangle(Rect::new(18.0, -0.9, 28.0, 0.9), 0.7)), + black(shapes::rounded_rectangle(Rect::new(36.0, -1.4, 48.0, 1.4), 0.9)), + black(shapes::rounded_rectangle(Rect::new(56.0, -0.8, 66.0, 0.8), 0.6)), + black(shapes::rounded_rectangle(Rect::new(74.0, -1.1, 86.0, 1.1), 0.8)), + black(shapes::rounded_rectangle(Rect::new(92.0, -0.7, 100.0, 0.7), 0.5)), + ]); + let brushes = vec![ + art("Laser Bolt", black(laser), Colorization::Tints), + art("Plasma Arc", black(plasma), Colorization::Tints), + art("Charge Wave", black(charge), Colorization::Tints), + art("Spark Trail", spark, Colorization::Tints), + ]; + built("Energy Beams", Document::new(100.0, 100.0), brushes) +} + // ---------- commands ---------- fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo { diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index e98593bfc8..491a471088 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails"]); + assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -118,6 +118,16 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] ); assert_eq!(libs[4]["count"].as_u64(), Some(4)); + let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"})); + assert_eq!( + beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), + ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] + ); + assert_eq!(libs[5]["count"].as_u64(), Some(4)); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); + let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); + assert_eq!(strs(&added["added"]), ["Laser Bolt"]); // One kind of brush per library, every brush drawing something. for (id, kind) in [ ("art-ribbons", "art"), @@ -125,6 +135,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ("pattern-borders", "pattern"), ("scatter-shapes", "scatter"), ("game-trails", "art"), + ("energy-beams", "art"), ] { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 82adcdb707..693de6f7b0 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5964,6 +5964,11 @@ axis Y Axis Y ос Dash Trail Пунктирна следа Speed Line Линия на скорост Game Trails Игрови следи + Energy Beams Енергийни лъчи + Laser Bolt Лазерен болт + Plasma Arc Плазмена дъга + Charge Wave Вълна на заряд + Spark Trail Следа от искри Calligraphic Nibs Калиграфски писци Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 70e5ee1ee8..57ba2813cb 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6014,6 +6014,11 @@ axis Y Axis Y osa Dash Trail Isprekidani trag Speed Line Linija brzine Game Trails Tragovi igre + Energy Beams Energetski snopovi + Laser Bolt Laserski metak + Plasma Arc Plazma luk + Charge Wave Talas naboja + Spark Trail Trag iskri Calligraphic Nibs Kaligrafski vrhovi Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9aadc2dd95..f48d20a2d5 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -368,6 +368,11 @@ antiAlias Sharp Nítid Dash Trail Estela discontínua Speed Line Línia de velocitat Game Trails Esteles de joc + Energy Beams Feixos d'energia + Laser Bolt Projectil làser + Plasma Arc Arc de plasma + Charge Wave Ona de càrrega + Spark Trail Estela d'espurnes Art using a deleted global swatch keeps its colour. L’art que utilitza una mostra global suprimida conserva el seu color. Artboard Tauler Artboard Options Opcions del tauler diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f23f842bc5..e9f94a2ecb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6198,6 +6198,11 @@ axis Y Axis osa Y Dash Trail Čárkovaná stopa Speed Line Rychlostní čára Game Trails Herní stopy + Energy Beams Energetické paprsky + Laser Bolt Laserový výstřel + Plasma Arc Plazmový oblouk + Charge Wave Nabíjecí vlna + Spark Trail Jiskrová stopa Calligraphic Nibs Kaligrafická pera Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bc878124b4..55a50c3abc 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5979,6 +5979,11 @@ axis Y Axis Y-akse Dash Trail Streget spor Speed Line Hastighedslinje Game Trails Spilspor + Energy Beams Energistråler + Laser Bolt Laserbolt + Plasma Arc Plasmabue + Charge Wave Opladningsbølge + Spark Trail Gnistsvær Calligraphic Nibs Kalligrafiske penne Pattern Borders Mønsterborter Scatter Shapes Spredte former diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index dea0ce4651..d29edd7249 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5781,6 +5781,11 @@ axis Y Axis Y-Achse Dash Trail Strichelspur Speed Line Geschwindigkeitslinie Game Trails Spielspuren + Energy Beams Energiestrahlen + Laser Bolt Laserbolzen + Plasma Arc Plasmabogen + Charge Wave Ladewelle + Spark Trail Funkenspur Calligraphic Nibs Kalligrafie-Federn Pattern Borders Musterbordüren Scatter Shapes Streuformen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 42ee612ea0..c57797f3b3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6149,6 +6149,11 @@ axis Y Axis Άξονας Y Dash Trail Διακεκομμένο ίχνος Speed Line Γραμμή ταχύτητας Game Trails Ίχνη παιχνιδιού + Energy Beams Δέσμες ενέργειας + Laser Bolt Βολίδα λέιζερ + Plasma Arc Τόξο πλάσματος + Charge Wave Κύμα φόρτισης + Spark Trail Ίχνος σπινθήρων Calligraphic Nibs Καλλιγραφικές πένες Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a92e2bfadf..c6fc3de160 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6140,6 +6140,11 @@ axis Y Axis Eje Y Dash Trail Estela discontinua Speed Line Línea de velocidad Game Trails Estelas de juego + Energy Beams Haces de energía + Laser Bolt Proyectil láser + Plasma Arc Arco de plasma + Charge Wave Onda de carga + Spark Trail Estela de chispas Calligraphic Nibs Plumillas caligráficas Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3ba6ebfdb2..44a9ebc3bd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6228,6 +6228,11 @@ axis Y Axis Y-akseli Dash Trail Katkoviiva Speed Line Nopeusviiva Game Trails Pelijäljet + Energy Beams Energiasäteet + Laser Bolt Laserpultti + Plasma Arc Plasma-kaari + Charge Wave Latausaalto + Spark Trail Kipinävanne Calligraphic Nibs Kalligrafiakärjet Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1056a5dccb..900ac39890 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6193,6 +6193,11 @@ axis Y Axis Axe Y Dash Trail Traînée tiretée Speed Line Ligne de vitesse Game Trails Traînées de jeu + Energy Beams Faisceaux d'énergie + Laser Bolt Trait laser + Plasma Arc Arc de plasma + Charge Wave Vague de charge + Spark Trail Traînée d'étincelles Calligraphic Nibs Plumes calligraphiques Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index c8b2dc57d3..b3034b9719 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -427,6 +427,11 @@ antiAlias Sharp शार्प Dash Trail डैश ट्रेल Speed Line स्पीड लाइन Game Trails गेम ट्रेल्स + Energy Beams ऊर्जा किरणें + Laser Bolt लेज़र बोल्ट + Plasma Arc प्लाज़्मा चाप + Charge Wave चार्ज तरंग + Spark Trail चिंगारी पथ Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है। Artboard आर्टबोर्ड Artboard {n} of {total} {total} में से आर्टबोर्ड {n} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d219482bc8..d118c3eb5e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6181,6 +6181,11 @@ axis Y Axis Y tengely Dash Trail Szaggatott nyom Speed Line Sebességvonal Game Trails Játéknyomok + Energy Beams Energianyalábok + Laser Bolt Lézervillám + Plasma Arc Plazmaív + Charge Wave Töltéshullám + Spark Trail Szikracsík Calligraphic Nibs Kalligrafikus tollhegyek Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1881d15f8c..9a60202ebe 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5979,6 +5979,11 @@ axis Y Axis Sumbu Y Dash Trail Jejak putus-putus Speed Line Garis kecepatan Game Trails Jejak game + Energy Beams Sinar Energi + Laser Bolt Bolt Laser + Plasma Arc Busur Plasma + Charge Wave Gelombang Muatan + Spark Trail Jejak Percikan Calligraphic Nibs Pena kaligrafi Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cd0bdf88b0..0cdf3d979f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5815,6 +5815,11 @@ axis Y Axis Asse Y Dash Trail Scia tratteggiata Speed Line Linea di velocità Game Trails Scia di gioco + Energy Beams Raggi di energia + Laser Bolt Dardo laser + Plasma Arc Arco al plasma + Charge Wave Onda di carica + Spark Trail Scia di scintille Calligraphic Nibs Pennini calligrafici Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9262cbf061..0646ef82ec 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5880,6 +5880,11 @@ axis Y Axis Y 軸 Dash Trail ダッシュトレイル Speed Line スピードライン Game Trails ゲームトレイル + Energy Beams エネルギービーム + Laser Bolt レーザーボルト + Plasma Arc プラズマアーク + Charge Wave チャージウェーブ + Spark Trail スパークトレイル Calligraphic Nibs カリグラフィペン先 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8fc3e844f8..a33afc4c0b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6121,6 +6121,11 @@ axis Y Axis Y ღერძი Dash Trail წყვეტილი კვალი Speed Line სიჩქარის ხაზი Game Trails თამაშის კვალი + Energy Beams ენერგიის სხივები + Laser Bolt ლაზერული ჭავლი + Plasma Arc პლაზმის რკალი + Charge Wave მუხტის ტალღა + Spark Trail ნაპერწკლის კვალი Calligraphic Nibs კალიგრაფიული წვერები Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 33efe3fc0b..a0a6aaa5ac 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5776,6 +5776,11 @@ axis Y Axis Y축 Dash Trail 대시 트레일 Speed Line 스피드 라인 Game Trails 게임 트레일 + Energy Beams 에너지 빔 + Laser Bolt 레이저 볼트 + Plasma Arc 플라즈마 아크 + Charge Wave 차지 웨이브 + Spark Trail 스파크 트레일 Calligraphic Nibs 서예 펜촉 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 940c29c872..565a04c6f1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6030,6 +6030,11 @@ axis Y Axis Y-as Dash Trail Streepspoor Speed Line Snelheidslijn Game Trails Spelsporen + Energy Beams Energiestralen + Laser Bolt Laserbout + Plasma Arc Plasmaboog + Charge Wave Laadgolf + Spark Trail Vonkenspoor Calligraphic Nibs Kalligrafische penpunten Pattern Borders Patroonranden Scatter Shapes Strooivormen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3508f8ff7c..7eacdc06bc 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6052,6 +6052,11 @@ axis Y Axis Oś Y Dash Trail Ślad kreskowany Speed Line Linia prędkości Game Trails Ślady gry + Energy Beams Wiązki energii + Laser Bolt Pocisk laserowy + Plasma Arc Łuk plazmowy + Charge Wave Fala ładunku + Spark Trail Ślad iskier Calligraphic Nibs Stalówki kaligraficzne Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4420ed889a..94bf89bea6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6113,6 +6113,11 @@ axis Y Axis Eixo Y Dash Trail Rastro tracejado Speed Line Linha de velocidade Game Trails Rastros de jogo + Energy Beams Feixes de energia + Laser Bolt Raio laser + Plasma Arc Arco de plasma + Charge Wave Onda de carga + Spark Trail Rastro de faíscas Calligraphic Nibs Penas Caligráficas Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ed2885af4c..0ee22fbf02 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5809,6 +5809,11 @@ axis Y Axis Ось Y Dash Trail Пунктирный след Speed Line Линия скорости Game Trails Игровые следы + Energy Beams Энергетические лучи + Laser Bolt Лазерный заряд + Plasma Arc Плазменная дуга + Charge Wave Волна заряда + Spark Trail Искра-след Calligraphic Nibs Каллиграфические перья Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 86b31cb8b2..c0ec7bbe61 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6221,6 +6221,11 @@ axis Y Axis Y-axel Dash Trail Streckat spår Speed Line Hastighetslinje Game Trails Spår från spel + Energy Beams Energistrålar + Laser Bolt Laserbult + Plasma Arc Plasmabåge + Charge Wave Laddningsvåg + Spark Trail Gnistsvår Calligraphic Nibs Kalligrafiska pennspetsar Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 61035b2e12..641d2b8395 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6154,6 +6154,11 @@ axis Y Axis Y ekseni Dash Trail Kesik iz Speed Line Hız çizgisi Game Trails Oyun izleri + Energy Beams Enerji Işınları + Laser Bolt Lazer Mermisi + Plasma Arc Plazma Yayı + Charge Wave Şarj Dalgası + Spark Trail Kıvılcım İzi Calligraphic Nibs Kaligrafi Uçları Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ea09c80e3c..d9302f651b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5795,6 +5795,11 @@ axis Y Axis Вісь Y Dash Trail Пунктирний слід Speed Line Лінія швидкості Game Trails Ігрові сліди + Energy Beams Енергетичні промені + Laser Bolt Лазерний заряд + Plasma Arc Плазмова дуга + Charge Wave Хвиля заряду + Spark Trail Слід іскор Calligraphic Nibs Каліграфічні пера Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 27a6b28fb2..5d4a87f3dd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5796,6 +5796,11 @@ axis Y Axis Trục Y Dash Trail Vệt đứt đoạn Speed Line Đường tốc độ Game Trails Vệt game + Energy Beams Chùm năng lượng + Laser Bolt Tia laser + Plasma Arc Cung plasma + Charge Wave Sóng nạp + Spark Trail Vệt tia lửa Calligraphic Nibs Ngòi bút thư pháp Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ab565b6373..0e317211c5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6036,6 +6036,11 @@ axis Y Axis Y 轴 Dash Trail 虚线轨迹 Speed Line 速度线 Game Trails 游戏轨迹 + Energy Beams 能量光束 + Laser Bolt 激光弹 + Plasma Arc 等离子弧 + Charge Wave 充能波 + Spark Trail 火花尾迹 Calligraphic Nibs 书法笔尖 Pattern Borders 图案边框 Scatter Shapes 散布形状 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index fec78601a3..e9d17ad8ff 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5833,6 +5833,11 @@ axis Y Axis Y 軸 Dash Trail 虛線軌跡 Speed Line 速度線 Game Trails 遊戲軌跡 + Energy Beams 能量光束 + Laser Bolt 雷射彈 + Plasma Arc 電漿弧 + Charge Wave 充能波 + Spark Trail 火花尾跡 Calligraphic Nibs 書法筆尖 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 22f863fbd9..6918e9118f 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -127,7 +127,17 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Brush Libraries"]; let opened: Vec<_> = entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.brushLibrary")).map(|e| e.params["library"].clone()).collect(); - assert_eq!(opened, [json!("art-ribbons"), json!("calligraphic-nibs"), json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails")]); + assert_eq!( + opened, + [ + json!("art-ribbons"), + json!("calligraphic-nibs"), + json!("pattern-borders"), + json!("scatter-shapes"), + json!("game-trails"), + json!("energy-beams") + ] + ); let enabled = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(enabled("window.brushLibrary.other"), Some(true)); assert_eq!(enabled("ui.saveBrushLibrary"), Some(true), "a new document has the default brushes"); diff --git a/docs/gaps.md b/docs/gaps.md index ecc1b2cf14..b722ace84c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -630,8 +630,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (five original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter - Shapes and Game Trails; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter + Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and @@ -649,7 +649,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, and Status Effects with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the five shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). @@ -854,6 +854,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | | 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | | 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | | 2026-11-11 | minor | G5: built-in Status Effects graphic style library (Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade, Shield Buff); Missing trimmed; estimate 7–17 → 6–16 h; MCP docs list UI Frames and Map Markers under Symbol libraries. | diff --git a/docs/mcp.md b/docs/mcp.md index 5d4c5cdbd8..edb9da9659 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3163,8 +3163,8 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: five generated in code (Art Ribbons, Calligraphic Nibs, Pattern -Borders, Scatter Shapes, Game Trails), the `.vectorcraft` files in the user library folder of the desktop app (User +Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern +Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). diff --git a/docs/roadmap.md b/docs/roadmap.md index 1326c42137..1b55740614 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, five generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | @@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | | 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | | 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged | | 2026-11-10 | minor | M10/G5: Inventory Icons built-in symbol library (Sword, Shield, Potion, Key, Gem); estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9599a6373d..d2bf47e7bd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -218,7 +218,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (five generated libraries including Game Trails, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -487,7 +487,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (five generated in code including Game Trails are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -539,7 +539,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~44% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries five (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -819,7 +819,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. @@ -1975,6 +1975,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | | 2026-11-11 | minor | Bundled content: built-in Status Effects graphic style library; style libraries evidence updated; ~40% → ~42%; MCP Symbol libraries list UI Frames and Map Markers; more built-in graphic-style libraries remain. | From 1643ab52a9b0965c97153dd7f1d77b69a2ce7acc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 10:10:05 -0800 Subject: [PATCH 48/49] Commands: ids null acts on the selection instead of being refused The 27 commands that act on `ids` or else the selection (Apply as Filter, Print Ink Fixes, the Arrange, Cleanup and Path Tools plug-in commands, Smooth, Text Wrap, variable.bind and variable.unbind among them) refused `ids: null` with "ids must be an array of non-negative integer object ids". They now read null as `ids` left out, as the other commands that take `ids` do. Remove Breaks also reads null as left out when it decides whether to act on the characters the Type tool selected. --- crates/engine/src/cmd.rs | 8 +- crates/engine/src/cmd/addons/cleanup/text.rs | 2 +- .../src/cmd/addons/cleanup/text/tests.rs | 12 +++ .../engine/src/tests/ids_without_selection.rs | 73 ++++++++++++++++--- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c76492f454..b728a0735b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -519,9 +519,13 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str } /// The objects a command acts on: `ids` when given (every one checked, and those inside another -/// given one dropped), else the selected objects. +/// given one dropped), else (`ids` left out or null) the selected objects. pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result> { - if p.get("ids").is_some() { Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?)) } else { edit::selected_roots(s) } + if p.get("ids").is_some_and(|v| !v.is_null()) { + Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?)) + } else { + edit::selected_roots(s) + } } pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 6e6abfaa48..10fd432dfc 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -806,7 +806,7 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { }; // The characters a Type tool has selected (after Select All in threaded text, all the text of // every frame of the story), else all the text of the selected type. - let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; + let editing = if p.get("ids").is_some_and(|v| !v.is_null()) { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; let story = if editing.is_some() { editing_story(s) } else { vec![] }; let d = &s.doc()?.doc; // Each type object with the breaks that go from it. diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index 27d0233bb6..2867dc3f1e 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -264,6 +264,18 @@ fn remove_breaks_in_the_characters_a_type_tool_selected() { assert_eq!(obj(&s, id).plain_text(), "a b\nc"); } +/// `ids: null` is `ids` left out: the characters the Type tool selected lose their breaks, and +/// the rest of the text keeps them. +#[test] +fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12})); + select(&mut s, &[id]); + edit_text(&mut s, id, 0, 2); + s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": null})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "a b\nc"); +} + #[test] fn split_at_the_cursor_and_around_a_selection() { for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index 8e26577f94..1e13c468c3 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -436,9 +436,7 @@ fn an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing() { /// With the rectangle apart (`d`) selected, the same commands with `ids` (or `slices`) left out and /// with it null do the same: what the selection's list does for the commands that take the /// selection, and the command's own default for the others (`object.slice.select` deselects the -/// slices). `addon.color.filter` and `addon.color.applyNearestSwatch` read `ids` through -/// [`cmd::checked_ids_or_selection`], which refuses null as a value that is not a list and changes -/// nothing. +/// slices). #[test] fn ids_left_out_or_null_take_the_selection_or_the_default() { let (_, [.., d], _) = doc_with_type(); @@ -450,15 +448,7 @@ fn ids_left_out_or_null_take_the_selection_or_the_default() { let mut null = params.clone(); null[key] = serde_json::Value::Null; let with_null = outcome(cmd, &null, &[d], slice); - if matches!(cmd, "addon.color.filter" | "addon.color.applyNearestSwatch") { - let (mut s, ..) = doc_with_type(); - s.execute("select.set", &json!({ "ids": [d] })).unwrap(); - let st = s.doc().unwrap(); - let refused = with_null.0.as_ref().is_err_and(|e| e.ends_with("ids must be an array of non-negative integer object ids")); - if !refused || (&with_null.1, &with_null.2, with_null.3) != (&*st.doc, &st.selection, st.history.undo.len()) { - failures.push(format!("{cmd} {key}: null is not refused with nothing changed ({:.160?})", with_null.0)); - } - } else if with_null != left_out { + if with_null != left_out { failures.push(format!("{cmd} {key}: null does not do what leaving it out does ({:.160?})", left_out.0)); } let mut listed = params.clone(); @@ -477,6 +467,65 @@ fn ids_left_out_or_null_take_the_selection_or_the_default() { assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); } +/// The commands that read `ids` through [`cmd::checked_ids_or_selection`] take `ids: null` as left +/// out: with the rectangle apart (`d`) or the type object (`t`) selected, null gives the same +/// result or error, document, selection and undo history as leaving `ids` out. Print Ink Fixes +/// runs in a CMYK document. +#[test] +fn ids_null_takes_the_selection_in_every_command_that_falls_back_to_it() { + let (_, [.., d], t) = doc_with_type(); + let mut failures = vec![]; + let (rect, both, text) = (&[d][..], &[d, t][..], &[t][..]); + let cases = [ + ("addon.arrange.randomizeOpacity", json!({"seed": 1}), rect), + ("addon.arrange.sort", json!({"by": "area", "apply": "x"}), both), + ("addon.cleanup.simplify", json!({}), rect), + ("addon.cleanup.deepUngroup", json!({}), rect), + ("addon.cleanup.rejoin", json!({}), rect), + ("addon.color.nearestSwatch", json!({}), rect), + ("addon.pathtools.cornerSet", json!({"radius": 2}), rect), + ("addon.pathtools.cornerRemove", json!({}), rect), + ("addon.pathtools.shape.convert", json!({}), rect), + ("addon.pathtools.erase", json!({"points": [[0, 20], [200, 20]]}), rect), + ("addon.pathtools.crossings", json!({}), rect), + ("addon.pathtools.expandDashes", json!({}), rect), + ("object.path.smooth", json!({}), rect), + ("object.textWrap.make", json!({}), rect), + ("object.textWrap.release", json!({}), rect), + ("object.textWrap.options", json!({}), rect), + ("variable.bind", json!({"variable": "v"}), rect), + ("variable.unbind", json!({}), rect), + ("addon.cleanup.breakText", json!({}), text), + ("addon.cleanup.combineText", json!({}), text), + ("addon.cleanup.joinText", json!({}), text), + ("addon.cleanup.removeBreaks", json!({"kind": "line"}), text), + ("addon.cleanup.resetTextTransform", json!({}), text), + ("addon.cleanup.alignInPlace", json!({"align": "left"}), text), + ]; + for (cmd, params, selected) in cases { + let left_out = outcome(cmd, ¶ms, selected, false); + let mut null = params.clone(); + null["ids"] = serde_json::Value::Null; + let with_null = outcome(cmd, &null, selected, false); + if with_null != left_out { + failures.push(format!("{cmd}: null gives {:.160?}; left out gives {:.160?}", with_null.0, left_out.0)); + } + } + let print_fixes = |p: &serde_json::Value| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk", "created": null})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let r = s.execute("addon.color.printFixes", p).map_err(|e| e.to_string()); + let st = s.doc().unwrap(); + (r, (*st.doc).clone(), st.selection.clone(), st.history.undo.len()) + }; + let (left_out, with_null) = (print_fixes(&json!({"inkLimit": 200})), print_fixes(&json!({"inkLimit": 200, "ids": null}))); + if with_null != left_out { + failures.push(format!("addon.color.printFixes: null gives {:.160?}; left out gives {:.160?}", with_null.0, left_out.0)); + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); +} + /// With no document open, every command that takes `ids` refuses a list with an entry that is not /// a non-negative integer, and a value that is not a list, with the same error as with a document /// open (`effect.list` and `links.findFiles` run without a document). From 3074560b07e752234f612ec93fa71c4347e36972 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 10:17:24 -0800 Subject: [PATCH 49/49] PDF: an option writes pages longer than 200 in at full size in points The Save PDF dialog's General section gets "Write pages longer than 200 in at full size in points" (longPagesInPoints in document.exportPdf, document.export, presets and document.pdfSettings), off by default. When it is on, a page with a side longer than 14,400 pt is written with its media box in points and no /UserUnit at every PDF version, and a PDF 1.5 file stays PDF 1.5. The export's warning for such a page then says it is written in points. With the option off, and for pages of 14,400 pt or less, the file is the same as before. Docs: docs/mcp.md, docs/target-app-parity.md. The new label has a row in every catalog that lists the dialog's other options. --- crates/engine/src/cmd/fileio/pdf.rs | 8 ++- crates/engine/src/tests/pdfpresets.rs | 40 ++++++++++++++ crates/pdf/src/export/entry.rs | 11 ++-- crates/pdf/src/settings.rs | 4 ++ crates/pdf/src/tests/marks.rs | 73 ++++++++++++++++++++++++++ crates/ui-egui/src/dialogs/save_pdf.rs | 4 ++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/mcp.md | 4 +- docs/target-app-parity.md | 2 +- 35 files changed, 165 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index ccafc96e5e..365edbedbf 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -95,6 +95,12 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ default: "false", description: "keep the layers whose Print option is off (left out otherwise, unless createLayers)", }, + FormatOption { + name: "longPagesInPoints", + ty: "boolean", + default: "false", + description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version", + }, FormatOption { name: "createLayers", ty: "boolean", @@ -122,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption; PDF/X files, 1.3 and 1.4 write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption; PDF/X files, 1.3 and 1.4 write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5; apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/tests/pdfpresets.rs b/crates/engine/src/tests/pdfpresets.rs index eee88c5d17..7de08463f3 100644 --- a/crates/engine/src/tests/pdfpresets.rs +++ b/crates/engine/src/tests/pdfpresets.rs @@ -123,6 +123,46 @@ fn presets_export_and_import_round_trip() { assert!(is_bad_params(Session::new().execute("pdf.preset.export", &json!({}))), "nothing saved to export"); } +/// `longPagesInPoints` is saved with a preset, comes back through a presets file and the +/// preferences, shows in `document.pdfSettings`, and a 10 m page exported with it reopens at 10 m. +/// A value that isn't a boolean is refused. +#[test] +fn long_pages_in_points_round_trips_through_presets_and_reopens_at_full_size() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 28_346.456_692_913_386, "height": 2_834.645_669_291_338_5})).unwrap(); + s.execute("pdf.preset.save", &json!({"name": "Banner", "longPagesInPoints": true, "preserveEditing": false})).unwrap(); + assert!(s.prefs.pdf_presets[0].settings.long_pages_in_points); + let back: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back.pdf_presets, s.prefs.pdf_presets); + let text = s.execute("pdf.preset.export", &json!({"names": ["Banner"]})).unwrap()["data"].as_str().unwrap().to_string(); + let mut t = session(); + t.execute("pdf.preset.import", &json!({"data": text})).unwrap(); + assert_eq!(t.prefs.pdf_presets, s.prefs.pdf_presets); + let v = s.execute("document.pdfSettings", &json!({"longPagesInPoints": true})).unwrap(); + assert_eq!((&v["settings"]["longPagesInPoints"], &v["changed"]), (&json!(true), &json!([{"option": "longPagesInPoints", "value": true}])), "{v}"); + assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["settings"]["longPagesInPoints"], json!(false)); + // The preset writes the page in points; the file reopens at 10 m. + let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap(); + let bytes = pdf(&v); + assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); + assert!( + v["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w.as_str().unwrap().ends_with("written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in")), + "{v}" + ); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "banner.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + let rect = o.doc().unwrap().doc.artboards[0].rect; + assert!((rect.width() - 28_346.46).abs() < 0.01 && (rect.height() - 2_834.65).abs() < 0.01, "{rect:?}"); + for bad in [json!("yes"), json!(1)] { + assert!(is_bad_params(s.execute("document.exportPdf", &json!({"longPagesInPoints": bad}))), "{bad}"); + assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "Bad", "longPagesInPoints": bad}))), "{bad}"); + } +} + #[test] fn saved_presets_persist_with_the_preferences() { let mut s = session(); diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 75b87209c5..8d99c1c852 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -192,9 +192,10 @@ const MIN_PAGE_SIDE: f64 = 3.0; const MAX_PDF14_REAL: f64 = 32_767.0; /// Pages with a side longer than [`MAX_PAGE_SIDE`] are written with a `UserUnit`: in PDF 1.6 and -/// later files that aren't PDF/X files. Other files have every page in points. +/// later files that aren't PDF/X files, unless `long_pages_in_points` is on. Other files have +/// every page in points. fn writes_user_units(set: &PdfSettings) -> bool { - set.compatibility.has_user_unit() && !set.standard.is_pdfx() + set.compatibility.has_user_unit() && !set.standard.is_pdfx() && !set.long_pages_in_points } /// The `UserUnit` (PDF 1.6) of a page `w` × `h` points large: 1 when both sides are at most @@ -220,15 +221,15 @@ pub fn check_page_sizes(doc: &Document, opts: &PdfOptions) -> Result<(), PdfErro /// The settings `set` is written with when one of `pages` has a side longer than /// [`MAX_PAGE_SIDE`]: a PDF 1.5 file that isn't a PDF/X file is written as PDF 1.6, which has a -/// page's `UserUnit` (`None`: `set` as it is). Other files without a `UserUnit` have the page in -/// points. The writer puts a whole number as an integer and fails a PDF 1.3 or 1.4 file that has +/// page's `UserUnit`, unless `long_pages_in_points` is on (`None`: `set` as it is). Other files +/// without a `UserUnit` have the page in points. The writer puts a whole number as an integer and fails a PDF 1.3 or 1.4 file that has /// a real number past [`MAX_PDF14_REAL`]: a page whose side is past it and not a whole number of /// points is refused here, before anything is drawn ([`page_too_long`]). fn for_long_pages(set: &PdfSettings, pages: &[(usize, PageBoxes)]) -> Result, PdfError> { if !pages.iter().any(|(_, b)| b.media.width().max(b.media.height()) > MAX_PAGE_SIDE) { return Ok(None); } - let raised = (set.compatibility == Compatibility::Pdf15 && !set.standard.is_pdfx()) + let raised = (set.compatibility == Compatibility::Pdf15 && !set.standard.is_pdfx() && !set.long_pages_in_points) .then(|| PdfSettings { compatibility: Compatibility::Pdf16, ..set.clone() }); // The writer's test for a whole number, on the `f32` it writes. let real = |side: f64| side > MAX_PDF14_REAL && (side as f32) as i32 as f32 != side as f32; diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 17f37f90b8..b4b25baebf 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -236,6 +236,10 @@ pub struct PdfSettings { /// Keep the layers whose Print option is off (they are left out otherwise, unless /// `create_layers` is on). pub include_non_printing: bool, + /// Write a page with a side longer than 14,400 pt (200 in) at its size in points, without a + /// `UserUnit`, at every PDF version (off: PDF 1.6 and later files that aren't PDF/X files + /// write it with a `UserUnit`, and PDF 1.5 files are written as PDF 1.6 for it). + pub long_pages_in_points: bool, pub compression: CompressionSettings, pub marks: MarkSettings, pub bleed: BleedSettings, diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 19031505d3..c829ed8d5a 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -418,6 +418,79 @@ fn a_page_within_14400_pt_is_written_as_before_at_every_version() { assert!(r.bytes.starts_with(b"%PDF-1.5") && has(b"/CFM/V2") && !has(b"AESV2")); } +/// 10 m in points. +const TEN_M: f64 = 10.0 / 0.0254 * 72.0; + +/// With `long_pages_in_points`, a 10 m page at PDF 1.7 is written at its size in points, with no +/// `UserUnit`, the warning says so, and the page reads back at 10 m. Without it the page keeps +/// its `UserUnit`. +#[test] +fn long_pages_in_points_writes_a_10_m_page_in_points() { + let d = sized(TEN_M, TEN_M / 10.0); + let r = report(&d, PdfSettings { long_pages_in_points: true, ..PdfSettings::default() }).unwrap(); + assert!(r.bytes.starts_with(b"%PDF-1.7")); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new()); + let media = raw_numbers(&r.bytes, "/MediaBox", 4); + assert!((media[2] - media[0] - 28_346.46).abs() < 0.01 && (media[3] - media[1] - 2_834.65).abs() < 0.01, "{media:?}"); + assert_eq!( + long_page_warnings(&r), + [ + "the page of artboard 1 is 28347 × 2835 pt (10.00 × 1.00 m), longer than PDF's page limit of 14,400 pt (200 in): PDF 1.7 pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in" + ], + "{:?}", + r.warnings + ); + let page = &info(&r.bytes, None).unwrap().pages[0]; + assert!((page.width - TEN_M).abs() < 0.01 && (page.height - TEN_M / 10.0).abs() < 0.01, "{page:?}"); + let back = import(&r.bytes).unwrap(); + let rect = back.artboards[0].rect; + assert!((rect.width() - TEN_M).abs() < 0.01 && (rect.height() - TEN_M / 10.0).abs() < 0.01, "{rect:?}"); + // Off (the default), the page has a `UserUnit` of 28,346.46 / 14,400, rounded up. + let off = report(&d, PdfSettings::default()).unwrap(); + assert_eq!(raw_numbers(&off.bytes, "/UserUnit", 1), [1.969]); + assert!( + long_page_warnings(&off)[0].ends_with("it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller") + ); + assert!(!settings(json!({})).long_pages_in_points, "off by default"); +} + +/// With `long_pages_in_points`, every PDF version writes a 10 m page in points: PDF 1.5 stays +/// PDF 1.5, and versions that write it in points anyway (PDF 1.3 and 1.4, PDF/X-4) give the same +/// file as without it. +#[test] +fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_in_points_already() { + let d = sized(TEN_M, TEN_M / 10.0); + let with = |set: &PdfSettings| PdfSettings { long_pages_in_points: true, ..set.clone() }; + for c in Compatibility::ALL.iter().copied() { + let set = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let r = report(&d, with(&set)).unwrap(); + assert!(r.bytes.starts_with(format!("%PDF-{}", c.id()).as_bytes()), "{}", c.label()); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{}", c.label()); + assert!((raw_meters(&r.bytes).0 - 10.0).abs() < 0.001, "{}", c.label()); + assert!(!r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.warnings); + assert_eq!(long_page_warnings(&r).len(), 1, "{}: {:?}", c.label(), r.warnings); + if matches!(c, Compatibility::Pdf13 | Compatibility::Pdf14) { + assert_eq!(r.bytes, report(&d, set).unwrap().bytes, "{}", c.label()); + } + } + let x4 = crate::builtin_presets().into_iter().find(|p| p.name == "PDF/X-4:2010").unwrap().settings; + assert_eq!(report(&d, with(&x4)).unwrap().bytes, report(&d, x4).unwrap().bytes); +} + +/// A page of 14,400 pt or less gives the same file with `long_pages_in_points` on or off, at every +/// PDF version. +#[test] +fn long_pages_in_points_leaves_a_page_within_14400_pt_as_it_is() { + let d = sized(14_400.0, 300.0); + for c in Compatibility::ALL.iter().copied() { + let off = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let on = PdfSettings { long_pages_in_points: true, ..off.clone() }; + let (off, on) = (report(&d, off).unwrap(), report(&d, on).unwrap()); + assert_eq!(on.bytes, off.bytes, "{}", c.label()); + assert_eq!(on.warnings, off.warnings, "{}", c.label()); + } +} + #[test] fn art_in_the_bleed_is_kept_and_clipped_to_the_bleed_box() { let d = doc(); diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index ee68105a62..ba6d206074 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -478,6 +478,10 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) { let layers = standard.allows_layers() && choice::(d, "compatibility").unwrap_or_default().has_layers(); flag(ui, d, "createLayers", tl!("Create PDF layers from layers and sublayers"), layers); flag(ui, d, "includeNonPrinting", tl!("Include non-printing layers"), true); + // PDF/X files and PDF 1.3 and 1.4 files always write long pages in points. + let compatibility = choice::(d, "compatibility").unwrap_or_default(); + let scales = !standard.is_pdfx() && !matches!(compatibility, Compatibility::Pdf13 | Compatibility::Pdf14); + flag(ui, d, "longPagesInPoints", tl!("Write pages longer than 200 in at full size in points"), scales); if editor { heading(ui, tl!("Description")); if let Some(text) = widgets::text_field_named(ui, "pdf-preset-description", Some(tl!("Description")), Some(&d.str("description")), 460.0, 3) { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e69dc7b97c..88f1a8431c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1604,6 +1604,7 @@ font-technology Variable Променливи Include Linked Files Включване на свързаните файлове Include Metadata Включване на метаданни Include non-printing layers Включване на слоеве, които не се печат + Write pages longer than 200 in at full size in points Записване на страници, по-дълги от 200 in, в пълен размер в точки Include SVG Code Включване на SVG код Include Swatches for Tints Включване на образци за тонове Inflate… Издуване… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index fc4610e0d0..c6a4d0777a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2878,6 +2878,7 @@ antiAlias Sharp Oštro General › Thumbnails Općenito › Sličice Grayscale images Slike u nijansama sive Include non-printing layers Uključi slojeve koji se ne štampaju + Write pages longer than 200 in at full size in points Upiši stranice duže od 200 in u punoj veličini, u tačkama Left: Lijevo: Mark as trapped Označi kao zarobljeno Marks and Bleeds Oznake i napusti diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 44562ac0f1..556302f9ff 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2045,6 +2045,7 @@ digits Malayalam Malaiàlam Include Linked Files Inclou els fitxers enllaçats Include Metadata Inclou les metadades Include non-printing layers Inclou les capes no imprimibles + Write pages longer than 200 in at full size in points Escriu les pàgines de més de 200 in a mida real, en punts Include Objects On Canvas Inclou els objectes del llenç Include SVG Code Inclou codi SVG Include Swatches for Tints Inclou mostres per als tons diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e42f9bd215..1bee701c22 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2908,6 +2908,7 @@ antiAlias Sharp Ostré General › Thumbnails Obecné › Miniatury Grayscale images Obrazy ve stupních šedi Include non-printing layers Zahrnout netisknutelné vrstvy + Write pages longer than 200 in at full size in points Zapsat stránky delší než 200 in v plné velikosti v bodech Left: Vlevo: Mark as trapped Označit jako s přesahy barev Marks and Bleeds Značky a spadávky diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index cf7aa67b3f..40ff520ca7 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2698,6 +2698,7 @@ antiAlias Sharp Skarp General › Thumbnails Generelt › Miniaturer Grayscale images Gråtonebilleder Include non-printing layers Medtag lag, der ikke udskrives + Write pages longer than 200 in at full size in points Skriv sider længere end 200 in i fuld størrelse i punkter Left: Venstre: Mark as trapped Markér som trappet Marks and Bleeds Mærker og udløb diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b339033e66..2606803c4b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1655,6 +1655,7 @@ file-filter Templates Vorlagen Include Linked Files Verknüpfte Dateien einschließen Include Metadata Metadaten einschließen Include non-printing layers Nicht druckende Ebenen einschließen + Write pages longer than 200 in at full size in points Seiten über 200 in in voller Größe in Punkt schreiben Include SVG Code SVG-Code einschließen Include Swatches for Tints Farbfelder für Tonwerte einschließen Inflate… Aufblasen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 114ed5a524..e892cc94f0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1578,6 +1578,7 @@ font-technology Variable Μεταβλητές Include Linked Files Συμπερίληψη συνδεδεμένων αρχείων Include Metadata Συμπερίληψη μεταδεδομένων Include non-printing layers Συμπερίληψη μη εκτυπώσιμων επιπέδων + Write pages longer than 200 in at full size in points Εγγραφή σελίδων μεγαλύτερων από 200 in σε πλήρες μέγεθος, σε στιγμές Include SVG Code Συμπερίληψη κώδικα SVG Include Swatches for Tints Συμπερίληψη δειγμάτων για αποχρώσεις Inflate… Φούσκωμα… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c0e50d82e9..f35dcd347f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1659,6 +1659,7 @@ font-technology Variable Variables Include Linked Files Incluir archivos vinculados Include Metadata Incluir metadatos Include non-printing layers Incluir capas no imprimibles + Write pages longer than 200 in at full size in points Escribir las páginas de más de 200 in a tamaño real, en puntos Include SVG Code Incluir código SVG Include Swatches for Tints Incluir muestras para matices Inflate… Inflar… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index cdbd875764..0951c9bd5f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2852,6 +2852,7 @@ antiAlias Sharp Terävä General › Thumbnails Yleiset › Pikkukuvat Grayscale images Harmaasävykuvat Include non-printing layers Sisällytä tulostumattomat tasot + Write pages longer than 200 in at full size in points Kirjoita yli 200 in pitkät sivut täysikokoisina pisteinä Left: Vasen: Mark as trapped Merkitse ylitäytetyksi Marks and Bleeds Merkit ja leikkuuvarat diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index cb81c4902c..322d701257 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1653,6 +1653,7 @@ font-technology Variable Variables Include Linked Files Inclure les fichiers liés Include Metadata Inclure les métadonnées Include non-printing layers Inclure les calques non imprimables + Write pages longer than 200 in at full size in points Écrire les pages de plus de 200 in en taille réelle, en points Include SVG Code Inclure le code SVG Include Swatches for Tints Inclure les nuances des teintes Inflate… Gonfler… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 296f18e805..662ae01758 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2257,6 +2257,7 @@ antiAlias Sharp शार्प Include Linked Files लिंक्ड फ़ाइलें शामिल करें Include Metadata मेटाडाटा शामिल Include non-printing layers गैर-प्रिंटिंग लेयर्स को शामिल करें + Write pages longer than 200 in at full size in points 200 in से लंबे पेज पूरे आकार में, पॉइंट में लिखें Include Objects On Canvas कैनवास पर ऑब्जेक्ट्स शामिल करें Include SVG Code SVG कोड शामिल करें Include Swatches for Tints टिंट्स के लिए स्वॉचेस शामिल करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 34b6be0ab7..8d602c5a6e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2069,6 +2069,7 @@ file-filter Templates Sablonok Include Linked Files Csatolt fájlok hozzáadása Include Metadata Metaadatok hozzáadása Include non-printing layers Nem nyomtatandó rétegek hozzáadása + Write pages longer than 200 in at full size in points 200 in-nél hosszabb oldalak írása teljes méretben, pontban Include SVG Code SVG-kód hozzáadása Include Swatches for Tints Tónusok színmintáinak felvétele Include tagged source profiles Címkézett forrásprofilok beágyazása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 16854b5bb7..499f0efd97 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2698,6 +2698,7 @@ antiAlias Sharp Tajam General › Thumbnails Umum › Miniatur Grayscale images Gambar skala abu-abu Include non-printing layers Sertakan lapisan non-cetak + Write pages longer than 200 in at full size in points Tulis halaman yang lebih panjang dari 200 in dalam ukuran penuh, dalam poin Left: Kiri: Mark as trapped Tandai sebagai ter-trap Marks and Bleeds Tanda dan bleed diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6a8f3d7253..926ba52692 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1686,6 +1686,7 @@ file-filter Templates Modelli Include Linked Files Includi file collegati Include Metadata Includi metadati Include non-printing layers Includi livelli non stampabili + Write pages longer than 200 in at full size in points Scrivi le pagine più lunghe di 200 in a grandezza reale, in punti Include SVG Code Includi codice SVG Include Swatches for Tints Includi campioni per tinte Inflate… Gonfia… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index aabe534b69..4907b20b1d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2002,6 +2002,7 @@ antiAlias Sharp シャープ Include SVG Code SVG コードを含める Include Swatches for Tints ティント用のスウォッチを含める Include non-printing layers 印刷しないレイヤーを含む + Write pages longer than 200 in at full size in points 200 in より長いページを実寸のポイントで書き出す Ink インキ Insert Character 文字を挿入 Inspect Document ドキュメントを調査 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index bb6a5da7d9..f559729aa4 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2572,6 +2572,7 @@ antiAlias Sharp მკვეთრი General › Thumbnails ზოგადი › ესკიზები Grayscale images ნაცრისფერი გამოსახულებები Include non-printing layers არაბეჭდვადი შრეების ჩართვა + Write pages longer than 200 in at full size in points 200 in-ზე გრძელი გვერდების სრული ზომით, პუნქტებში ჩაწერა Left: მარცხნივ: Mark as trapped ტრაპინგის მარკირება Marks and Bleeds ნიშნები და ჭრისპირა არეები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b7f5824635..bda3545744 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2031,6 +2031,7 @@ antiAlias Sharp 날카롭게 Include SVG Code SVG 코드 포함 Include Swatches for Tints 농도별 견본 포함 Include non-printing layers 인쇄되지 않는 레이어 포함 + Write pages longer than 200 in at full size in points 200 in보다 긴 페이지를 실제 크기의 포인트로 기록 Ink 잉크 Insert Character 문자 삽입 Inspect Document 문서 검사 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 32db6785bb..c1c1645206 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2712,6 +2712,7 @@ antiAlias Sharp Scherp General › Thumbnails Algemeen › Miniaturen Grayscale images Grijswaardenafbeeldingen Include non-printing layers Niet-afdrukbare lagen opnemen + Write pages longer than 200 in at full size in points Pagina's langer dan 200 in op ware grootte in punten schrijven Left: Links: Mark as trapped Markeren als getrapt Marks and Bleeds Steker en afloop diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 398adf5f50..0603c6ac1e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2798,6 +2798,7 @@ antiAlias Sharp Ostre General › Thumbnails Ogólne › Miniatury Grayscale images Obrazy w skali szarości Include non-printing layers Uwzględnij warstwy niedrukowane + Write pages longer than 200 in at full size in points Zapisuj strony dłuższe niż 200 in w pełnym rozmiarze, w punktach Left: Lewy: Mark as trapped Oznacz jako z zalewkami Marks and Bleeds Znaczniki i spady diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9ede5521a8..e5a83ab2c3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1456,6 +1456,7 @@ antiAlias Sharp Nítido Include SVG Code Incluir Código SVG Include Swatches for Tints Incluir Amostras para Tons Include non-printing layers Incluir camadas de não impressão + Write pages longer than 200 in at full size in points Gravar páginas com mais de 200 in em tamanho real, em pontos Inflate… Inflar… Info Informações Ink Tinta diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 50eb9e969d..268e4439bc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1683,6 +1683,7 @@ file-filter Templates Шаблоны Include Linked Files Включать связанные файлы Include Metadata Включать метаданные Include non-printing layers Включать непечатаемые слои + Write pages longer than 200 in at full size in points Записывать страницы длиннее 200 in в полном размере, в пунктах Include SVG Code Включать код SVG Include Swatches for Tints Включать образцы для оттенков Inflate… Надувание… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 60324bb91e..74d20983b7 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1617,6 +1617,7 @@ font-technology Variable Variabla Include Linked Files Inkludera länkade filer Include Metadata Inkludera metadata Include non-printing layers Inkludera lager som inte skrivs ut + Write pages longer than 200 in at full size in points Skriv sidor längre än 200 in i full storlek i punkter Include SVG Code Inkludera SVG-kod Include Swatches for Tints Inkludera färgrutor för nyanser Inflate… Blås upp… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 496dba8cf5..0b7fc1b205 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1497,6 +1497,7 @@ font-technology Variable Değişken Include Linked Files Bağlantılı Dosyaları Ekle Include Metadata Meta Verileri Ekle Include non-printing layers Yazdırılmayan katmanları dahil et + Write pages longer than 200 in at full size in points 200 in'den uzun sayfaları tam boyutta, punto olarak yaz Include SVG Code SVG Kodunu Ekle Include Swatches for Tints Renk Tonları için Renk Örneklerini Ekle Inflate… Şişir… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c1b1c3bdaf..c758a4051d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1680,6 +1680,7 @@ file-filter Templates Шаблони Include Linked Files Включати пов’язані файли Include Metadata Включати метадані Include non-printing layers Включати недруковані шари + Write pages longer than 200 in at full size in points Записувати сторінки, довші за 200 in, у повному розмірі, у пунктах Include SVG Code Вмикати код SVG Include Swatches for Tints Включати зразки для відтінків Inflate… Надування… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d458c348ee..2eb4cd3292 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1950,6 +1950,7 @@ digits Hindi Hindi Include Linked Files Bao gồm tệp được liên kết Include Metadata Bao gồm siêu dữ liệu Include non-printing layers Bao gồm các lớp không in + Write pages longer than 200 in at full size in points Ghi các trang dài hơn 200 in ở kích thước đầy đủ, theo điểm Include SVG Code Bao gồm mã SVG Include Swatches for Tints Bao gồm mẫu màu cho sắc pha Increase Font Size Tăng cỡ chữ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 028bd60571..d68ce3070e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1613,6 +1613,7 @@ file-filter Templates 模板 Include Linked Files 包含链接文件 Include Metadata 包含元数据 Include non-printing layers 包含非打印图层 + Write pages longer than 200 in at full size in points 以实际尺寸(点)写入长于 200 in 的页面 Include SVG Code 包含 SVG 代码 Include Swatches for Tints 包含淡色色板 Inflate… 膨胀… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7cb218f99f..b203d30577 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1651,6 +1651,7 @@ file-filter Templates 範本 Include Linked Files 包含連結檔案 Include Metadata 包含中繼資料 Include non-printing layers 包含不列印的圖層 + Write pages longer than 200 in at full size in points 以實際尺寸(點)寫入長於 200 in 的頁面 Include SVG Code 包含 SVG 程式碼 Include Swatches for Tints 包含色調的色票 Inflate… 膨脹… diff --git a/docs/mcp.md b/docs/mcp.md index f799c98c5e..6287a3c738 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -862,7 +862,9 @@ A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2). A PDF 1.5 file with such a page is written as PDF 1.6, with a warning, and keeps PDF 1.5's encryption (128-bit RC4). PDF/X files and PDF 1.3 and 1.4 files write such a page in points, and apps -that keep to the limit cut it to 200 in. In PDF 1.3 and 1.4 files, PDF/X-1a and PDF/X-3 among them, a real number can +that keep to the limit cut it to 200 in. `longPagesInPoints: true` (off by default; "Write pages longer than 200 in at +full size in points" in the Save PDF dialog's General section) writes such a page in points at every PDF version, without +a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. In PDF 1.3 and 1.4 files, PDF/X-1a and PDF/X-3 among them, a real number can be at most 32,767, and whole numbers are written as integers: a page longer than 32,767 pt whose sides aren't whole numbers of points fails the export with an error naming the page before the thumbnails are drawn and the transparency is flattened, and one whose art needs a real number past 32,767 fails with the same error when the file is written. A page whose short side is 3 pt or longer but would be under 3 units at that diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6a1d02a5d..1997fb5b1f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1435,7 +1435,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). - PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). + PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version. PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one. Layers the file leaves out are left out of the flattening, and transparent art that a clipping path hides entirely is removed (`crates/engine/src/cmd/fileio/tests_pdfx.rs`). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392).