diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 90424d5bc..f64bdb430 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -159,6 +159,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects }, BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, BuiltinStyleLibrary { id: "game-hud", name: "Game HUD", make: game_hud }, + BuiltinStyleLibrary { id: "status-effects", name: "Status Effects", make: status_effects }, ]; /// Built-in library `id`, made once. @@ -482,6 +483,43 @@ fn game_hud() -> Vec { ] } +/// Status Effects: buffs and debuffs for RPG/gamedev art (original, catalogue stylize effects only). +fn status_effects() -> Vec { + let stealth = GraphicStyle { + opacity: 0.45, + blend: BlendMode::Normal, + ..style("Stealth Fade", vec![F(fill("#7f8c8d")), S(stroke("#2c3e50", 1.0))], vec![]) + }; + vec![ + style( + "Poison Tint", + vec![F(fill("#2ecc71"))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 80.0, "blur": 6.0, "color": "#58d68d"}))], + ), + style( + "Freeze Edge", + vec![F(fill("#aed6f1")), S(stroke("#5dade2", 2.0))], + vec![fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 70.0, "blur": 5.0, "color": "#d6eaf8", "source": "edge"}))], + ), + style( + "Burn Aura", + vec![F(fill("#e67e22"))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 85.0, "blur": 8.0, "color": "#f39c12"}))], + ), + style( + "Stun Pulse", + vec![F(fill("#f4d03f"))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 75.0, "blur": 7.0, "color": "#f7dc6f"}))], + ), + stealth, + style( + "Shield Buff", + vec![F(fill("#85c1e9")), S(round("#2874a6", 2.0))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 70.0, "blur": 5.0, "color": "#5dade2"}))], + ), + ] +} + #[cfg(test)] mod tests { use super::*; @@ -508,6 +546,10 @@ mod tests { assert_eq!(game.name, "Game HUD"); let names: Vec<&str> = game.styles.iter().map(|g| g.name.as_str()).collect(); assert_eq!(names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]); + let status = builtin_style_library("status-effects").unwrap(); + assert_eq!(status.name, "Status Effects"); + let status_names: Vec<&str> = status.styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); } #[test] diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index fa2137c9d..e1b88d5d9 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -272,9 +272,9 @@ impl EndedLink { } /// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it -/// measured. A command that replaces art calls this before it changes anything, since replacing -/// the art a dimension measures would otherwise leave the dimension unlinked, and then -/// [`restore_unchanged`]. +/// measured. A command that replaces art calls this before it changes anything and +/// [`restore_unchanged`] after, so that a dimension whose own art the command changed stays a +/// plain group. pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec { let mut ended = Vec::new(); for id in reached(doc, roots) { @@ -287,18 +287,156 @@ pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec ended } -/// The dimensions in `ended` whose art is as it was are live again. -pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec) { +/// The dimensions in `ended` whose own art is as it was are live again. One whose measured art the +/// command changed from `before` so that the art no longer fits its link stays where it was, no +/// longer linked, as [`refresh`] leaves a dimension whose art was deleted. +pub(crate) fn restore_unchanged(before: &Document, doc: &mut Document, ended: Vec) { for e in ended { - if doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) - && let Some(n) = doc.node_mut(e.id) - { - n.extras_mut().dimension = Some(Box::new(e.recipe)); + if !doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) { + continue; + } + let recipe = if e.source_kept(before, doc) { e.recipe } else { unlinked(&e.recipe) }; + if let Some(n) = doc.node_mut(e.id) { + n.extras_mut().dimension = Some(Box::new(recipe)); doc.dimensions = true; } } } +/// `old` where it was, no longer linked. A move it had on its own and not yet drawn is kept when +/// the moved dimension is valid. +fn unlinked(old: &Dimension) -> Dimension { + let mut unlinked = old.clone(); + unlinked.source = None; + unlinked.moved = None; + if let Some(v) = old.moved { + let mut shifted = unlinked.clone(); + (shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v); + if shifted.validate().is_ok() { + unlinked = shifted; + } + } + unlinked +} + +/// The whole dimensions under `roots` that are selected together with art they measure (art under +/// `roots`), each one a root or held by groups without fills, strokes or effects of their own. +/// Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave their +/// lines and label as they are. A group with an appearance of its own changes everything it holds +/// when it expands, its dimensions included. +pub(crate) fn with_their_art(doc: &Document, roots: &[NodeId]) -> Vec { + if !doc.dimensions { + return Vec::new(); + } + let mut found = Vec::new(); + for root in roots { + let Some(n) = doc.node(*root) else { continue }; + n.walk(&mut |c| { + if let Some(source) = c.extras().dimension.as_ref().and_then(|d| d.source.as_ref()) { + found.push((*root, c.id, source.nodes())); + } + }); + } + let under_roots = |id: NodeId| doc.ancestry(id).is_some_and(|a| a.iter().any(|a| roots.contains(a))); + found + .into_iter() + .filter(|(root, id, _)| { + let Some(ancestry) = doc.ancestry(*id) else { return false }; + let from = ancestry.iter().position(|a| a == root).unwrap_or(ancestry.len()); + ancestry.get(from..ancestry.len().saturating_sub(1)).unwrap_or_default().iter().all(|a| doc.node(*a).is_some_and(plain_group)) + }) + .filter(|(_, _, measured)| measured.iter().any(|m| under_roots(*m))) + .map(|(_, id, _)| id) + .collect() +} + +/// A group or layer without fills, strokes or effects of its own. +fn plain_group(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && n.appearance.items.is_empty() && n.appearance.effects.is_empty() +} + +/// A dimension [`lift`] took out of the document, with its place, the export assets that list it or +/// its parts (with their places in the asset list) and its parts' variable bindings. +pub(crate) struct Lifted { + parent: Option, + index: usize, + node: Arc, + assets: Vec<(usize, vectorcraft_doc::assets::ExportAsset)>, + bindings: Vec<(NodeId, std::collections::BTreeSet)>, +} + +impl Lifted { + pub(crate) fn id(&self) -> NodeId { + self.node.id + } +} + +/// Takes the [`with_their_art`] dimensions under `roots` out of `doc`, so a command that replaces +/// art does not reach them; [`put_back`] puts them back. +pub(crate) fn lift(doc: &mut Document, roots: &[NodeId]) -> Vec { + let mut lifted = Vec::new(); + for id in with_their_art(doc, roots) { + let Some((parent, index, _)) = doc.position(id) else { continue }; + let mut parts = std::collections::BTreeSet::new(); + if let Some(n) = doc.node(id) { + n.walk(&mut |c| { + parts.insert(c.id); + }); + } + let assets = doc.assets.iter().enumerate().filter(|(_, a)| a.nodes.iter().any(|n| parts.contains(n))).map(|(i, a)| (i, a.clone())).collect(); + let bindings = doc.variables.bindings.iter().filter(|(n, _)| parts.contains(n)).map(|(n, b)| (*n, b.clone())).collect(); + if let Ok(node) = doc.remove(id) { + lifted.push(Lifted { parent, index, node, assets, bindings }); + } + } + lifted +} + +/// Each dimension [`lift`] took out, as it was, back in its place in `doc`, with its parts back in +/// the export assets that listed them and their variable bindings back: edits made while it was out +/// let go of them. A dimension whose group is gone goes on top of the first layer. +pub(crate) fn put_back(doc: &mut Document, lifted: Vec) { + for l in lifted.into_iter().rev() { + let parent = if doc.children(l.parent).is_some() { l.parent } else { doc.layers.first().map(|layer| layer.id) }; + let Ok(ch) = doc.children_mut(parent) else { continue }; + let i = if parent == l.parent { l.index.min(ch.len()) } else { ch.len() }; + ch.insert(i, l.node); + doc.dimensions = true; + for (at, saved) in l.assets { + let there: Vec = saved.nodes.iter().copied().filter(|n| doc.node(*n).is_some()).collect(); + if let Some(a) = doc.assets.iter_mut().find(|a| a.id == saved.id) { + for (k, n) in there.iter().enumerate() { + if !a.nodes.contains(n) { + let k = k.min(a.nodes.len()); + a.nodes.insert(k, *n); + } + } + } else if !there.is_empty() { + let at = at.min(doc.assets.len()); + doc.assets.insert(at, vectorcraft_doc::assets::ExportAsset { nodes: there, ..saved }); + } + } + for (n, b) in l.bindings { + doc.variables.bindings.entry(n).or_insert(b); + } + } +} + +/// `doc` without the [`with_their_art`] dimensions under `roots`, to see what a command that leaves +/// them alone would change: `doc` itself when there are none. +pub(crate) fn without_kept<'a>(doc: &'a Document, roots: &[NodeId]) -> std::borrow::Cow<'a, Document> { + let ids = with_their_art(doc, roots); + if ids.is_empty() { + return std::borrow::Cow::Borrowed(doc); + } + let mut view = doc.clone(); + for id in ids { + // A dimension already gone (inside another one removed) needs nothing more. + let _ = view.remove(id); + } + std::borrow::Cow::Owned(view) +} + /// Called inside every edit, so dimensions and their undo history stay in step with the art they /// measure. Returns at once in documents without dimensions. pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result { @@ -320,17 +458,7 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result { // The art it measured was deleted, or rebuilt with other anchors, or no longer fits // (an ellipse that was a circle): it stays where it was, no longer linked, in this // same undo step, rather than attaching to a different edge. - let mut unlinked = old.clone(); - unlinked.source = None; - unlinked.moved = None; - if let Some(v) = moved { - let mut shifted = unlinked.clone(); - (shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v); - if shifted.validate().is_ok() { - unlinked = shifted; - } - } - if replace(doc, id, unlinked).is_err() + if replace(doc, id, unlinked(&old)).is_err() && let Some(n) = doc.node_mut(id) { // Its recipe cannot be drawn again: its art stays as it is, as a plain group. diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index a19455bd5..ade33d4be 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -674,11 +674,19 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> { /// fills, strokes or effects of its own or such members, other objects with effects, or type with /// fills or strokes of its own? pub(crate) fn expandable(n: &Node) -> bool { + expandable_except(n, &[]) +} + +/// [`expandable`], leaving out the objects `skip` lists and what they hold. +pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool { + if skip.contains(&n.id) { + return false; + } match &n.kind { NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } => false, NodeKind::Path { .. } | NodeKind::Compound { .. } => !n.appearance.is_basic() || vectorcraft_brush::has_brush(n), NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c)) + !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip)) } NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(), NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(), @@ -688,9 +696,11 @@ pub(crate) fn expandable(n: &Node) -> bool { fn can_expand(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - let d = &s.doc().map_err(|e| e.to_string())?.doc; let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?; - if ids.iter().filter_map(|id| d.node(*id)).any(expandable) { + let d = &s.doc().map_err(|e| e.to_string())?.doc; + // A dimension selected with the art it measures is left as it is. + let kept = super::dimension::with_their_art(d, &ids); + if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) { Ok(()) } else { Err("select (or target) objects whose appearance isn't basic".into()) @@ -975,7 +985,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { // In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the // geometry it hasn't changed. if vectorcraft_plugins::safe_mode::active() { - let d = &s.doc()?.doc; + // A dimension selected with the art it measures is left as it is. + let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots); let mut seen = std::collections::HashSet::new(); if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) { return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into())); @@ -985,20 +996,24 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { let before = d.clone(); - let mut ended = super::dimension::end_links(d, &roots); + // A dimension selected with the art it measures keeps its own lines and label: it is out + // of the document while the art expands. + let lifted = super::dimension::lift(d, &roots); + let kept = !lifted.is_empty(); + let ended = super::dimension::end_links(d, &roots); let brushes = vectorcraft_brush::library(d); let mut out = vec![]; let threaded = super::threads::frames(d); for id in &roots { expand_node(d, *id, &brushes, &mut out); } - // A dimension whose source was replaced stays plain: its link no longer fits that art. - ended.retain(|e| e.source_kept(&before, d)); - super::dimension::restore_unchanged(d, ended); + super::dimension::put_back(d, lifted); + // A dimension whose source was replaced stays where it was, no longer linked. + super::dimension::restore_unchanged(&before, d, ended); // Outlined frames of threaded text leave their threads with the text they show; type that // stays type under a new id takes its frame's place. super::threads::leave_threads(d, &threaded); - if out.is_empty() { + if out.is_empty() || (kept && d.layers == before.layers) { return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into())); } super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad("Expand Appearance", e))?; diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index c5c0fbd65..ffb58d9d4 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -5,7 +5,8 @@ //! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform //! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip //! group shaped like the object. Create Gradient Mesh shares the sampling. A dimension whose art -//! Expand changes becomes a plain group of that art. +//! Expand changes becomes a plain group of that art, except one selected with art it measures, +//! which Expand leaves as it is. use std::collections::BTreeSet; use std::sync::Arc; @@ -43,7 +44,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -55,7 +56,8 @@ pub fn specs() -> Vec { "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)", has_selection, |s, _| { - let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); + let roots = selected_roots(s)?; + let e = Expandable::of(&super::dimension::without_kept(&s.doc()?.doc, &roots), &roots); let mut info = json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient, "appearance": has_appearance(s)}); if e.symbols { @@ -74,7 +76,9 @@ pub fn specs() -> Vec { /// (#3089); `object.expand` itself still bakes them. pub fn has_appearance(s: &Session) -> bool { let (Ok(st), Ok(roots)) = (s.doc(), selected_roots(s)) else { return false }; - roots.iter().filter_map(|r| st.doc.node(*r)).any(super::effectcmd::expandable) + // A dimension selected with the art it measures is left as it is. + let kept = super::dimension::with_their_art(&st.doc, &roots); + roots.iter().filter_map(|r| st.doc.node(*r)).any(|n| super::effectcmd::expandable_except(n, &kept)) } /// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it @@ -241,9 +245,18 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { let stroke = switch_or(p, "stroke", true, C)?; let to = GradientTo::parse(p)?; let from = s.doc()?.history.undo.len(); - let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); // Before anything expands: outlining the art a dimension measures would leave the dimension unlinked. let before = s.doc()?.doc.clone(); + let selected = s.doc()?.selection.objects.clone(); + // A dimension selected with the art it measures keeps its own lines and label: it is out of the + // document while the art expands, so no step below reaches it. + let roots = selected_roots(s)?; + let mut lifted = vec![]; + if !super::dimension::with_their_art(&before, &roots).is_empty() { + lifted = s.edit("Expand", |d, _| Ok(super::dimension::lift(d, &roots)))?; + } + let kept: Vec = lifted.iter().map(super::dimension::Lifted::id).collect(); + let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); let mut ended = vec![]; end_dimension_links(s, &mut ended)?; let rev0 = s.doc()?.revision; @@ -339,14 +352,25 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { if s.doc()?.revision == rev0 { return Err(EngineError::Other("Expand: nothing to expand".into())); } - if !ended.is_empty() { - // A dimension whose source Expand replaced stays plain: its link no longer fits that art. - let after = s.doc()?.doc.clone(); - ended.retain(|e| e.source_kept(&before, &after)); - s.edit("Expand", |d, _| { - super::dimension::restore_unchanged(d, std::mem::take(&mut ended)); + if !ended.is_empty() || !lifted.is_empty() { + s.edit("Expand", |d, sel| { + if !lifted.is_empty() { + super::dimension::put_back(d, lifted); + // The dimensions put back follow what Expand made of the art they measure: a + // dimension whose art no longer fits its link stays where it was, no longer linked. + super::dimension::refresh(&before, d)?; + let mut ids = sel.objects.clone(); + let again: Vec = kept.iter().copied().filter(|id| selected.contains(id) && !ids.contains(id)).collect(); + ids.extend(again); + sel.set(ids); + } + // A dimension whose source Expand replaced stays where it was, no longer linked. + super::dimension::restore_unchanged(&before, d, ended); Ok(()) })?; + if !kept.is_empty() && s.doc()?.doc.layers == before.layers { + return Err(EngineError::Other("Expand: nothing to expand".into())); + } } squash(s, from, "Expand"); Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::>() })) diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 82de6fa93..c8453c8c0 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -135,7 +135,7 @@ pub fn specs() -> Vec { "Outline Stroke", ["Object", "Path"], None, - "{} replace stroked paths with filled outlines; a dimension whose lines are outlined, or selected with the art it measures when that art is outlined, becomes a plain group of its art → {ids}", + "{} replace stroked paths with filled outlines; a dimension whose lines are outlined becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines stay as they are, and if the outlined art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, outline_stroke ), @@ -1074,7 +1074,11 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Outline Stroke", |d, sel| { let before = d.clone(); - let mut ended = super::dimension::end_links(d, &roots); + // A dimension selected with the art it measures keeps its own lines: it is out of the + // document while the art is outlined. + let lifted = super::dimension::lift(d, &roots); + let kept = !lifted.is_empty(); + let ended = super::dimension::end_links(d, &roots); let brushes = vectorcraft_brush::library(d); let mut new_sel = vec![]; let mut changed = 0; @@ -1083,10 +1087,10 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { new_sel.push(id); changed += n; } - // A dimension whose source was outlined stays plain: its link no longer fits that art. - ended.retain(|e| e.source_kept(&before, d)); - super::dimension::restore_unchanged(d, ended); - if changed == 0 { + super::dimension::put_back(d, lifted); + // A dimension whose source was outlined stays where it was, no longer linked. + super::dimension::restore_unchanged(&before, d, ended); + if changed == 0 || (kept && d.layers == before.layers) { return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into())); } sel.set(new_sel.iter().copied()); diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 66f3f8bf8..d49ea7843 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?: {} (no prompts exist yet: only an empty object is taken), 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; 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 off is taken; #strict on, #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, 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)}, 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 off is taken; #strict on, #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", always, run ), @@ -131,10 +131,9 @@ fn run(s: &mut Session, p: &Value) -> Result { if serde_json::to_vec(&args).map_or(true, |b| b.len() > script::MAX_ARGS_BYTES) { return Err(param("`args` is larger than 1 MiB")); } - match p.get("answers") { - None => {} - Some(Value::Object(o)) if o.is_empty() => {} - Some(_) => return Err(param("`answers` must be an empty object: scripts cannot ask questions yet")), + let answers = script::dialogs::parse_answers(p.get("answers")).map_err(param)?; + if s.script_frames && !answers.is_empty() { + return Err(param("`answers` is only for a run with nobody to ask: in the app, the person answers each dialog")); } let fuel = max_fuel(p)?; let text = match (path, source) { @@ -162,7 +161,7 @@ fn run(s: &mut Session, p: &Value) -> Result { _ => return Err(param("give exactly one of `path` and `source`")), }; let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string()); - let request = script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel }; + let request = script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel, answers }; let name = s.script_start(request)?; if s.script_frames { return Ok(json!({"running": true, "name": name})); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 0ddd14594..48959c54e 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -596,6 +596,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { if texts.is_empty() { return Err(EngineError::Other("Create Outlines: select text objects".into())); } + let before = (!super::dimension::reached(d, &roots).is_empty()).then(|| d.clone()); let ended = super::dimension::end_links(d, &roots); let mut new_sel: Vec = roots.iter().copied().filter(|r| !texts.contains(r)).collect(); let mut out = vec![]; @@ -691,7 +692,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { new_sel.push(gid); } } - super::dimension::restore_unchanged(d, ended); + if let Some(before) = &before { + super::dimension::restore_unchanged(before, d, ended); + } // Outlined frames of threaded text leave their threads with the text they show. super::threads::leave_threads(d, &threaded); sel.set(new_sel); diff --git a/crates/engine/src/script/console.rs b/crates/engine/src/script/console.rs index eda6feeb4..5a3b40ffe 100644 --- a/crates/engine/src/script/console.rs +++ b/crates/engine/src/script/console.rs @@ -207,7 +207,7 @@ impl Console { /// `c` as the console shows it: a control character as its Control Picture, a line or paragraph /// separator as U+2424, anything else as it is. -fn shown(c: char) -> char { +pub(crate) fn shown(c: char) -> char { match c { '\u{0}'..='\u{1f}' => char::from_u32(0x2400 + c as u32).unwrap_or('\u{fffd}'), '\u{7f}' => '\u{2421}', diff --git a/crates/engine/src/script/dialogs.rs b/crates/engine/src/script/dialogs.rs new file mode 100644 index 000000000..1faf624f1 --- /dev/null +++ b/crates/engine/src/script/dialogs.rs @@ -0,0 +1,364 @@ +//! `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and +//! `prompt(message, preset[, title])`: the dialogs a script opens while it runs. +//! +//! A message and a preset arrive as the string `String(value)` gives, a title the same way unless +//! it is left out or `undefined` ([`DEFAULT_TITLE`]), and `errorIcon` and `noAsDflt` as the +//! boolean `Boolean(value)` gives. `alert` returns `undefined`, `confirm` `true` for Yes and +//! `false` for No, and `prompt` the text in its field for OK and `null` for Cancel. +//! +//! In the app the run waits at the call for the person's answer, and spends no fuel while it +//! waits; every dialog also has Stop, which ends the run and puts everything back. A run with +//! nobody to ask takes its answers, in order, from `script.run`'s `answers`; a call with no +//! answer left, or with the next answer for another kind of dialog, ends the run. The report's +//! `dialogs` lists each dialog and its answer. +//! +//! The values here are VectorCraft's own limits: [`MAX_MESSAGE_BYTES`], [`MAX_TITLE_BYTES`], +//! [`MAX_PROMPT_BYTES`], [`MAX_ANSWERS`], [`MAX_ANSWERS_BYTES`], [`MAX_RECORDS`] and +//! [`MAX_RECORD_BYTES`]. + +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::rc::Rc; + +use serde_json::{Map, Value, json}; +use vectorcraft_js::{Arg, Data, HostError, HostFunction, Realm, RunError}; + +use super::{Host, console}; + +/// The most bytes (UTF-8) of a dialog's message. +pub const MAX_MESSAGE_BYTES: usize = 16 << 10; +/// The most bytes (UTF-8) of a dialog's title. +pub const MAX_TITLE_BYTES: usize = 256; +/// The most bytes (UTF-8) of a prompt's preset and of the text it returns. +pub const MAX_PROMPT_BYTES: usize = 16 << 10; +/// The most answers `script.run` takes. +pub const MAX_ANSWERS: usize = 10_000; +/// The most bytes of `answers`, as JSON. +pub const MAX_ANSWERS_BYTES: usize = 1 << 20; +/// The most dialogs a report lists. +pub const MAX_RECORDS: usize = 10_000; +/// The most bytes of the dialogs a report lists, as JSON. +pub const MAX_RECORD_BYTES: usize = 1 << 20; +/// A dialog's title when the script gives none, in English; the app translates it when it shows it. +pub const DEFAULT_TITLE: &str = "Script Alert"; + +/// Which dialog a script opened. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DialogKind { + Alert, + Confirm, + Prompt, +} + +impl DialogKind { + /// The function's name, which is also the `kind` of an answer and of a report's dialog. + pub fn id(self) -> &'static str { + match self { + Self::Alert => "alert", + Self::Confirm => "confirm", + Self::Prompt => "prompt", + } + } +} + +/// A dialog a script opened, which waits for its answer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScriptDialog { + pub kind: DialogKind, + pub message: String, + /// `None`: the script gave no title ([`DEFAULT_TITLE`]). + pub title: Option, + /// `alert`: show the error icon. + pub error_icon: bool, + /// `confirm`: No is the default button. + pub no_as_default: bool, + /// `prompt`: the text its field starts with. + pub preset: String, +} + +/// The answer to a [`ScriptDialog`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DialogAnswer { + /// OK, for `alert`. + Ok, + /// Yes (`true`) or No, for `confirm`. + Confirm(bool), + /// OK with the field's text, or Cancel (`None`), for `prompt`. + Prompt(Option), +} + +impl DialogAnswer { + pub fn kind(&self) -> DialogKind { + match self { + Self::Ok => DialogKind::Alert, + Self::Confirm(_) => DialogKind::Confirm, + Self::Prompt(_) => DialogKind::Prompt, + } + } + + /// What the function returns. + fn data(&self) -> Data { + match self { + Self::Ok => Data::Undefined, + Self::Confirm(b) => Data::Bool(*b), + Self::Prompt(Some(t)) => Data::String(t.clone()), + Self::Prompt(None) => Data::Null, + } + } + + /// The answer as a report gives it: `"ok"`, a boolean, a string or `null`. + fn json(&self) -> Value { + match self { + Self::Ok => json!("ok"), + Self::Confirm(b) => json!(b), + Self::Prompt(t) => json!(t), + } + } +} + +/// `text` as a dialog shows it. With `lines`, a line feed, a carriage return or the two together +/// is a line break; every other control character, and with `lines` false every line break too, +/// shows as its Unicode Control Picture, as the Script Console shows it. +pub fn shown_text(text: &str, lines: bool) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\r' if lines => { + chars.next_if_eq(&'\n'); + out.push('\n'); + } + '\n' if lines => out.push('\n'), + c => out.push(console::shown(c)), + } + } + out +} + +/// The longest start of `text` that is at most `max` bytes, cut at a character boundary. +pub fn cut_to(text: &str, max: usize) -> &str { + if text.len() <= max { + return text; + } + let mut end = max; + while !text.is_char_boundary(end) { + end -= 1; + } + text.get(..end).unwrap_or_default() +} + +/// `script.run`'s `answers`: a list of `{kind: "alert", value: "ok"}`, `{kind: "confirm", value: +/// true | false}` and `{kind: "prompt", value: text | null}`. An empty object, which the parameter +/// took before dialogs existed, is an empty list. +pub(crate) fn parse_answers(v: Option<&Value>) -> Result, String> { + let list = match v { + None => return Ok(Vec::new()), + Some(Value::Object(o)) if o.is_empty() => return Ok(Vec::new()), + Some(Value::Array(a)) => a, + Some(_) => return Err("`answers` must be a list of answers".into()), + }; + if list.len() > MAX_ANSWERS { + return Err(format!("`answers` has more than {MAX_ANSWERS} answers")); + } + if serde_json::to_vec(list).map_or(true, |b| b.len() > MAX_ANSWERS_BYTES) { + return Err("`answers` is larger than 1 MiB".into()); + } + list.iter().enumerate().map(|(i, a)| answer(a).map_err(|e| format!("`answers[{i}]` {e}"))).collect() +} + +fn answer(a: &Value) -> Result { + let Some(o) = a.as_object() else { return Err("must be an object with `kind` and `value`") }; + if o.len() != 2 || !o.contains_key("value") { + return Err("must have exactly `kind` and `value`"); + } + match (o.get("kind").and_then(Value::as_str), &o["value"]) { + (Some("alert"), Value::String(s)) if s == "ok" => Ok(DialogAnswer::Ok), + (Some("alert"), _) => Err("is an alert's answer, whose `value` must be \"ok\""), + (Some("confirm"), Value::Bool(b)) => Ok(DialogAnswer::Confirm(*b)), + (Some("confirm"), _) => Err("is a confirm's answer, whose `value` must be true or false"), + (Some("prompt"), Value::Null) => Ok(DialogAnswer::Prompt(None)), + (Some("prompt"), Value::String(s)) if s.len() <= MAX_PROMPT_BYTES => Ok(DialogAnswer::Prompt(Some(s.clone()))), + (Some("prompt"), Value::String(_)) => Err("is longer than 16 KiB"), + (Some("prompt"), _) => Err("is a prompt's answer, whose `value` must be a string or null"), + _ => Err("must have `kind` alert, confirm or prompt"), + } +} + +/// The dialogs of one run: the answers left for a run with nobody to ask, the dialog that waits +/// for the person's answer, and the list the report gives. +pub(crate) struct Dialogs { + /// Answers come from the person, not from `answers`. + framed: bool, + /// The realm's [`Realm::wait_flag`]. + can_wait: Rc>, + answers: RefCell>, + waiting: RefCell>, + /// The error that ended the run when an answer could not reach the script. + failed: RefCell>, + records: RefCell>, + record_bytes: Cell, + count: Cell, + truncated: Cell, +} + +impl Dialogs { + pub(crate) fn new(framed: bool, realm: &Realm, answers: Vec) -> Self { + Self { + framed, + can_wait: realm.wait_flag(), + answers: RefCell::new(answers.into()), + waiting: RefCell::default(), + failed: RefCell::default(), + records: RefCell::default(), + record_bytes: Cell::new(0), + count: Cell::new(0), + truncated: Cell::new(false), + } + } + + /// The dialog that waits for its answer. + pub(crate) fn waiting(&self) -> Option { + self.waiting.borrow().clone() + } + + pub(crate) fn is_waiting(&self) -> bool { + self.waiting.borrow().is_some() + } + + /// The error that ended the run when its answer could not reach the script. + pub(crate) fn take_failure(&self) -> Option { + self.failed.borrow_mut().take() + } + + /// How many of `answers` the run did not use. + pub(crate) fn unused(&self) -> usize { + self.answers.borrow().len() + } + + /// Gives the waiting dialog `answer`, which must be for its kind and, for a prompt, at most + /// [`MAX_PROMPT_BYTES`]. → false when it is not, or nothing waits. + pub(crate) fn answer(&self, realm: &mut Realm, answer: DialogAnswer) -> bool { + let fits = match &answer { + DialogAnswer::Prompt(Some(t)) => t.len() <= MAX_PROMPT_BYTES, + _ => true, + }; + let mut waiting = self.waiting.borrow_mut(); + let Some(d) = waiting.as_ref().filter(|d| d.kind == answer.kind() && fits) else { return false }; + self.record(d, answer.json()); + *waiting = None; + if let Err(e) = realm.answer(answer.data()) { + *self.failed.borrow_mut() = Some(e); + } + true + } + + /// The run ends while a dialog waits: the report lists it with the answer `"stop"`. + pub(crate) fn stopped(&self) { + if let Some(d) = self.waiting.borrow_mut().take() { + self.record(&d, json!("stop")); + } + } + + fn record(&self, d: &ScriptDialog, answer: Value) { + self.count.set(self.count.get().saturating_add(1)); + let mut r = Map::new(); + r.insert("kind".into(), json!(d.kind.id())); + r.insert("message".into(), json!(d.message)); + r.insert("title".into(), json!(d.title.as_deref().unwrap_or(DEFAULT_TITLE))); + match d.kind { + DialogKind::Alert => r.insert("errorIcon".into(), json!(d.error_icon)), + DialogKind::Confirm => r.insert("noAsDefault".into(), json!(d.no_as_default)), + DialogKind::Prompt => r.insert("preset".into(), json!(d.preset)), + }; + r.insert("answer".into(), answer); + let r = Value::Object(r); + let bytes = serde_json::to_vec(&r).map_or(usize::MAX, |b| b.len()); + let mut records = self.records.borrow_mut(); + if self.truncated.get() || records.len() >= MAX_RECORDS || self.record_bytes.get().saturating_add(bytes) > MAX_RECORD_BYTES { + self.truncated.set(true); + return; + } + self.record_bytes.set(self.record_bytes.get() + bytes); + records.push(r); + } + + /// Adds `dialogs`, `dialogCount` and `dialogsTruncated` to a report. + pub(crate) fn report(&self, report: &mut Map) { + report.insert("dialogs".into(), Value::Array(self.records.borrow().clone())); + report.insert("dialogCount".into(), json!(self.count.get())); + report.insert("dialogsTruncated".into(), json!(self.truncated.get())); + } +} + +/// Adds `alert`, `confirm` and `prompt` to the realm. +pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError> { + use Arg::{Bool, Text, TextOrUndefined}; + let call = |kind: DialogKind| -> HostFunction { + let h = host.clone(); + Rc::new(move |args| open(&h, kind, &args)) + }; + realm.define_host_function("alert", vec![Text, TextOrUndefined, Bool], call(DialogKind::Alert))?; + realm.define_host_function("confirm", vec![Text, Bool, TextOrUndefined], call(DialogKind::Confirm))?; + realm.define_host_function("prompt", vec![Text, Text, TextOrUndefined], call(DialogKind::Prompt)) +} + +fn text(args: &[Data], i: usize) -> Option { + match args.get(i) { + Some(Data::String(s)) => Some(s.clone()), + _ => None, + } +} + +fn flag(args: &[Data], i: usize) -> bool { + matches!(args.get(i), Some(Data::Bool(true))) +} + +/// A call of `alert`, `confirm` or `prompt`. +fn open(host: &Host, kind: DialogKind, args: &[Data]) -> Result { + let name = kind.id(); + host.charge_call(args.iter().map(super::data_size).sum())?; + let Some(message) = text(args, 0) else { return Err(HostError::Type(format!("{name}: give a message"))) }; + let (title, error_icon, no_as_default, preset) = match kind { + DialogKind::Alert => (text(args, 1), flag(args, 2), false, String::new()), + DialogKind::Confirm => (text(args, 2), false, flag(args, 1), String::new()), + DialogKind::Prompt => { + let Some(preset) = text(args, 1) else { return Err(HostError::Type(format!("{name}: give the text the field starts with"))) }; + (text(args, 2), false, false, preset) + } + }; + if message.len() > MAX_MESSAGE_BYTES { + return Err(HostError::Range(format!("{name}: the message is longer than 16 KiB"))); + } + if title.as_ref().is_some_and(|t| t.len() > MAX_TITLE_BYTES) { + return Err(HostError::Range(format!("{name}: the title is longer than 256 bytes"))); + } + if preset.len() > MAX_PROMPT_BYTES { + return Err(HostError::Range(format!("{name}: the text the field starts with is longer than 16 KiB"))); + } + let dialog = ScriptDialog { kind, message, title, error_icon, no_as_default, preset }; + let d = &host.dialogs; + if d.framed { + if !d.can_wait.get() { + return Err(HostError::Error(format!( + "{name}: a dialog cannot open inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code" + ))); + } + *d.waiting.borrow_mut() = Some(dialog); + return Err(HostError::Wait); + } + let next = d.answers.borrow_mut().pop_front(); + match next { + Some(a) if a.kind() == kind => { + d.record(&dialog, a.json()); + Ok(a.data()) + } + Some(a) => { + d.record(&dialog, json!("missing")); + Err(HostError::Abort(format!("{name}: the next answer in `answers` is for {}, not {name}", a.kind().id()))) + } + None => { + d.record(&dialog, json!("missing")); + Err(HostError::Abort(format!("{name}: `answers` has no answer left for this dialog"))) + } + } +} diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index f2e4cc5ba..15d6c06e3 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -32,10 +32,16 @@ //! there too. The session's console gets a start line, the run's output, its uncaught error, an end //! line and a report line; the run's report holds its own output and error, its time, its host //! calls by kind and its slowest commands. +//! +//! `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`. pub mod console; +pub mod dialogs; pub mod include; +pub use dialogs::{DialogAnswer, DialogKind, ScriptDialog}; + use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::Rc; @@ -88,7 +94,7 @@ const MAX_BRIDGE_BYTES: u64 = 4 << 30; /// The version `vc.api.version` gives. pub const API_VERSION: &str = "1.0"; /// The features `vc.api.has` knows. -pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console"]; +pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dialogs"]; /// The most commands a run's report lists in `slowestCommands`. pub const SLOWEST_COMMANDS: usize = 5; @@ -147,6 +153,8 @@ pub(crate) struct Request { pub source: String, pub args: Map, pub fuel: u64, + /// The answers to the dialogs of a run with nobody to ask, in order. + pub answers: Vec, } thread_local! { @@ -480,6 +488,8 @@ struct Host { commands: RefCell>, /// The time the run's slices took, in nanoseconds. run_nanos: Cell, + /// `alert`, `confirm` and `prompt`: their answers and the list the report gives. + dialogs: dialogs::Dialogs, /// The files the run reads: its includes and `$.evalFile`'s. files: RefCell, /// Where the lines of the script's expanded source came from. @@ -1061,7 +1071,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", "alert", "confirm", "prompt", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"]; + const HOST_OBJECTS: &[&str] = &["app", "File", "Folder", "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 { @@ -1163,6 +1173,23 @@ impl Session { !self.script_stop.is_set() && self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.waiting.get())).unwrap_or(false) } + /// The dialog (`alert`, `confirm` or `prompt`) the running script waits at for the person's + /// answer. A run asked to stop no longer waits: the next [`Session::script_pump`] ends it. + pub fn script_dialog(&self) -> Option { + if self.script_stop.is_set() { + return None; + } + self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.dialogs.waiting())).flatten() + } + + /// Gives the dialog the running script waits at its answer; the next [`Session::script_pump`] + /// goes on from the call. → false when nothing waits, the answer is for another kind of + /// dialog, or a prompt's text is longer than [`dialogs::MAX_PROMPT_BYTES`]. + pub fn script_answer(&mut self, answer: DialogAnswer) -> bool { + let Some(k) = self.script_key.as_ref().filter(|_| !self.script_stop.is_set()) else { return false }; + with_job(k, |j| j.host.dialogs.answer(&mut j.realm, answer)).unwrap_or(false) + } + /// The name of the running script. pub fn script_name(&self) -> Option { self.script_key.as_ref().and_then(|k| with_job(k, |j| j.name.clone())) @@ -1270,6 +1297,7 @@ impl Session { counts: Counts::default(), commands: RefCell::default(), run_nanos: Cell::new(0), + dialogs: dialogs::Dialogs::new(self.script_frames, &realm, request.answers), files: RefCell::new(files), script_map: RefCell::default(), eval_maps: RefCell::default(), @@ -1286,6 +1314,7 @@ impl Session { realm .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(|()| realm.start(&expanded.source)) .map_err(|e| located(&host, realm.error_source(), &e)) } @@ -1312,11 +1341,20 @@ impl Session { let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else { return Some(Err(EngineError::Other(JOB_GONE.to_string()))); }; - if job.host.waiting.get() && !self.script_stop.is_set() { + if let Some(e) = job.host.dialogs.take_failure() { + return Some(self.script_failed(job, &e)); + } + if (job.host.waiting.get() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() { JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job)); self.script_key = Some(key); return None; } + // A stop while a dialog waits ends the run there; the interpreter cannot go on from the call. + if job.realm.waits() { + let kind = LimitKind::Interrupted; + let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 }; + return Some(self.script_failed(job, &e)); + } if vectorcraft_doc::cancel::check().is_err() { self.script_stop.set(); } @@ -1333,6 +1371,12 @@ impl Session { self.script_key = Some(key); None } + Ok(Ok(Progress::Done(_))) if job.host.dialogs.unused() > 0 => { + let n = job.host.dialogs.unused(); + let kind = LimitKind::Host; + let message = format!("`answers` has {n} answer{} the script did not use", if n == 1 { "" } else { "s" }); + Some(self.script_failed(job, &RunError { kind: RunErrorKind::Limit(kind), message, line: 0, column: 0 })) + } Ok(Ok(Progress::Done(_))) => { let r = self.script_commit(job); self.trim_histories(); @@ -1342,6 +1386,7 @@ impl Session { } fn script_failed(&mut self, job: Job, e: &RunError) -> Result { + job.host.dialogs.stopped(); let fuel_left = job.realm.fuel_left(); let (e, file) = located(&job.host, job.realm.error_source(), e); let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left)); @@ -1438,7 +1483,7 @@ impl Session { if let Some(result) = self.script_pump() { return result; } - if self.script_waiting() { + if self.script_waiting() || self.script_dialog().is_some() { self.script_stop.set(); } } @@ -1504,8 +1549,8 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) { /// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run /// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report -/// line in the session's console. Adds the run's time, host calls by kind, slowest commands and -/// console to `report`. +/// line in the session's console. Adds the run's time, host calls by kind, slowest commands, +/// console and dialogs to `report`. fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::Ending) { let mut pending = std::mem::take(&mut *host.pending.borrow_mut()); if !pending.is_empty() { @@ -1539,6 +1584,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console:: o.insert("hostCallCounts".into(), host.counts.json()); o.insert("slowestCommands".into(), Value::Array(slow_json)); o.insert("console".into(), host.console.borrow().report()); + host.dialogs.report(o); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9ad671843..2af42d292 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -151,6 +151,7 @@ mod save; mod saveoptions; mod scalestrokes; mod script_console; +mod script_dialogs; mod script_folder; mod script_include; mod script_run; diff --git a/crates/engine/src/tests/script_dialogs.rs b/crates/engine/src/tests/script_dialogs.rs new file mode 100644 index 000000000..7572ca52f --- /dev/null +++ b/crates/engine/src/tests/script_dialogs.rs @@ -0,0 +1,289 @@ +//! `alert`, `confirm` and `prompt` in a script: their arguments and results, the answers a run with +//! nobody to ask takes from `answers`, the wait in the app, Stop, and the report's `dialogs`. + +use serde_json::{Value, json}; + +use crate::script::{DialogAnswer, DialogKind, ScriptDialog, dialogs}; +use crate::*; + +/// One open document with one rectangle. +fn one_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s +} + +fn art(s: &Session) -> (String, usize) { + let d = &s.documents()[0]; + (format!("{:?}", d.doc.layers), d.history.undo.len()) +} + +fn headless(s: &mut Session, source: &str, answers: Value) -> Result { + s.execute("script.run", &json!({"source": source, "name": "d.js", "answers": answers})) +} + +/// A run in the app of `source`, one slice in. +fn framed(s: &mut Session, source: &str) { + s.script_frames = true; + assert_eq!(s.execute("script.run", &json!({"source": source, "name": "d.js"})).unwrap()["running"], true); + assert!(s.script_pump().is_none()); +} + +/// Pumps the run in the app until it ends or waits at a dialog (`None`). +fn pump(s: &mut Session) -> Option> { + for _ in 0..10_000 { + if s.script_dialog().is_some() { + return None; + } + if let Some(r) = s.script_pump() { + return Some(r); + } + } + panic!("the run neither ended nor waited"); +} + +fn answers(list: &[Value]) -> Value { + Value::Array(list.to_vec()) +} + +#[test] +fn notification_signatures_and_returns() { + let mut s = one_doc(); + let source = "vc.log(typeof alert('a')); alert(42, 'Title', 1);\ + vc.log(confirm('c')); vc.log(confirm('n', true, undefined));\ + vc.log(prompt('p', 'pre')); vc.log(prompt({toString: function () { return 'own'; }}, '', 'T'));"; + let r = headless( + &mut s, + source, + answers(&[ + json!({"kind": "alert", "value": "ok"}), + json!({"kind": "alert", "value": "ok"}), + json!({"kind": "confirm", "value": true}), + json!({"kind": "confirm", "value": false}), + json!({"kind": "prompt", "value": "typed"}), + json!({"kind": "prompt", "value": null}), + ]), + ) + .unwrap(); + assert_eq!(r["log"], json!(["undefined", true, false, "typed", null]), "{r}"); + assert_eq!( + r["dialogs"], + json!([ + {"kind": "alert", "message": "a", "title": "Script Alert", "errorIcon": false, "answer": "ok"}, + {"kind": "alert", "message": "42", "title": "Title", "errorIcon": true, "answer": "ok"}, + {"kind": "confirm", "message": "c", "title": "Script Alert", "noAsDefault": false, "answer": true}, + {"kind": "confirm", "message": "n", "title": "Script Alert", "noAsDefault": true, "answer": false}, + {"kind": "prompt", "message": "p", "title": "Script Alert", "preset": "pre", "answer": "typed"}, + {"kind": "prompt", "message": "own", "title": "T", "preset": "", "answer": null}, + ]) + ); + assert_eq!((r["dialogCount"].clone(), r["dialogsTruncated"].clone()), (json!(6), json!(false))); + let has = headless(&mut s, "vc.log(vc.api.has('dialogs'));", json!([])).unwrap(); + assert_eq!(has["log"], json!([true])); +} + +#[test] +fn headless_answers_follow_call_order() { + let mut s = one_doc(); + let source = "var out = []; [1, 2].forEach(function (i) { out.push(confirm('q' + i)); }); out.push(prompt('r', 'x')); vc.log(out.join());"; + let r = headless( + &mut s, + source, + answers(&[json!({"kind": "confirm", "value": false}), json!({"kind": "confirm", "value": true}), json!({"kind": "prompt", "value": "z"})]), + ) + .unwrap(); + assert_eq!(r["log"], json!(["false,true,z"]), "a callback of a built-in takes its answer too: {r}"); + assert_eq!(headless(&mut s, "1", json!({})).unwrap()["dialogCount"], 0, "an empty object is an empty list"); +} + +#[test] +fn missing_or_mismatched_answer_rolls_back() { + let mut s = one_doc(); + let before = art(&s); + let edit = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});"; + for (source, given, says) in [ + (format!("{edit} try {{ alert('x'); }} catch (e) {{ vc.log('caught'); }}"), json!([]), "no answer left"), + (format!("{edit} alert('x');"), answers(&[json!({"kind": "confirm", "value": true})]), "is for confirm, not alert"), + (format!("{edit} alert('x');"), answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})]), "1 answer"), + ] { + let e = headless(&mut s, &source, given).unwrap_err().to_string(); + assert!(e.contains(says), "{e}"); + assert_eq!(art(&s), before, "{source}"); + let report = s.script_report().unwrap(); + assert_eq!(report["kind"], "limit", "{report}"); + assert_eq!(report["log"], json!([]), "a missing answer cannot be caught"); + assert_eq!(report["dialogCount"], 1); + } + assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "ok"); + headless(&mut s, "alert('x')", json!([])).unwrap_err(); + assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "missing"); +} + +#[test] +fn invalid_answers_are_rejected_before_run() { + let mut s = one_doc(); + let before = art(&s); + let many: Vec = (0..=dialogs::MAX_ANSWERS).map(|_| json!({"kind": "alert", "value": "ok"})).collect(); + let big: Vec = (0..80).map(|_| json!({"kind": "prompt", "value": "x".repeat(16_000)})).collect(); + for given in [ + json!({"a": 1}), + json!("ok"), + json!([1]), + json!([{"kind": "alert"}]), + json!([{"kind": "alert", "value": true}]), + json!([{"kind": "confirm", "value": "yes"}]), + json!([{"kind": "prompt", "value": 3}]), + json!([{"kind": "prompt", "value": "x".repeat(dialogs::MAX_PROMPT_BYTES + 1)}]), + json!([{"kind": "other", "value": "ok"}]), + json!([{"kind": "alert", "value": "ok", "extra": 1}]), + Value::Array(many), + Value::Array(big), + ] { + let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});"; + let r = s.execute("script.run", &json!({"source": source, "answers": given})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); + assert_eq!(art(&s), before); + } + s.script_frames = true; + let r = s.execute("script.run", &json!({"source": "1", "answers": [{"kind": "alert", "value": "ok"}]})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "a run in the app takes no answers: {r:?}"); + assert!(!s.script_running()); +} + +#[test] +fn a_framed_run_waits_at_each_dialog_and_takes_the_persons_answer() { + let mut s = one_doc(); + framed( + &mut s, + "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); var a = confirm('Go?', true, 'Ask'); var b = prompt('Name', 'n'); vc.log([a, b]);", + ); + let want = ScriptDialog { + kind: DialogKind::Confirm, + message: "Go?".into(), + title: Some("Ask".into()), + error_icon: false, + no_as_default: true, + preset: String::new(), + }; + assert_eq!(s.script_dialog(), Some(want)); + assert!(!s.script_answer(DialogAnswer::Ok), "an answer for another kind of dialog is refused"); + assert!(s.script_answer(DialogAnswer::Confirm(true))); + assert!(!s.script_answer(DialogAnswer::Confirm(true)), "nothing waits until the run goes on"); + assert!(pump(&mut s).is_none()); + assert_eq!(s.script_dialog().unwrap().preset, "n"); + assert!(!s.script_answer(DialogAnswer::Prompt(Some("x".repeat(dialogs::MAX_PROMPT_BYTES + 1))))); + assert!(s.script_answer(DialogAnswer::Prompt(Some("Ann".into())))); + let r = pump(&mut s).unwrap().unwrap(); + assert_eq!(r["log"], json!([[true, "Ann"]])); + assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script", "a finished run is one undo step"); + assert_eq!(r["dialogs"][1]["answer"], "Ann"); +} + +#[test] +fn prompt_cancel_returns_null() { + let mut s = one_doc(); + framed(&mut s, "vc.log(prompt('Name', '') === null);"); + assert!(s.script_answer(DialogAnswer::Prompt(None))); + assert_eq!(pump(&mut s).unwrap().unwrap()["log"], json!([true])); +} + +#[test] +fn dialog_loop_stops_or_exhausts() { + let mut s = one_doc(); + let before = art(&s); + let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); while (1) { alert('x'); }"; + let three: Vec = (0..3).map(|_| json!({"kind": "alert", "value": "ok"})).collect(); + let e = headless(&mut s, source, Value::Array(three)).unwrap_err().to_string(); + assert!(e.contains("no answer left"), "{e}"); + assert_eq!(s.script_report().unwrap()["dialogCount"], 4); + assert_eq!(art(&s), before); + framed(&mut s, source); + for _ in 0..3 { + assert!(s.script_answer(DialogAnswer::Ok)); + assert!(pump(&mut s).is_none()); + } + assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true); + assert_eq!(s.script_dialog(), None, "a run asked to stop no longer waits"); + assert!(!s.script_answer(DialogAnswer::Ok)); + assert!(s.script_pump().unwrap().is_err()); + let report = s.script_report().unwrap(); + assert_eq!((report["kind"].as_str(), report["dialogCount"].as_u64()), (Some("stopped"), Some(4)), "{report}"); + assert_eq!(report["dialogs"][3]["answer"], "stop"); + assert_eq!(art(&s), before, "Stop at the first or any later dialog leaves no undo step"); + framed(&mut s, source); + assert!(s.script_abandon(), "quitting while a dialog waits abandons the run"); + assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "stop"); + assert_eq!(art(&s), before); +} + +#[test] +fn dialog_text_limits_and_controls() { + let mut s = one_doc(); + let source = format!( + "var out = [];\ + function t(f) {{ try {{ f(); out.push('opened'); }} catch (e) {{ out.push(e.name); }} }}\ + t(function () {{ alert('x'); }});\ + t(function () {{ alert(new Array({m}).join('m')); }});\ + t(function () {{ alert('m', new Array({t}).join('t')); }});\ + t(function () {{ prompt('m', new Array({m}).join('p')); }});\ + t(function () {{ alert(); }});\ + t(function () {{ prompt('m'); }});\ + t(function () {{ alert({{toString: function () {{ throw new Error('no'); }}}}); }});\ + t(function () {{ alert(undefined); }});\ + vc.log(out.join());", + m = dialogs::MAX_MESSAGE_BYTES + 2, + t = dialogs::MAX_TITLE_BYTES + 2, + ); + let r = headless(&mut s, &source, answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})])).unwrap(); + assert_eq!(r["log"], json!(["opened,RangeError,RangeError,RangeError,TypeError,TypeError,Error,opened"]), "{r}"); + assert_eq!(r["dialogs"][1]["message"], "undefined", "an explicit undefined message is a message"); + assert_eq!(dialogs::shown_text("a\r\nb\rc\nd\te\u{7}\u{7f}", true), "a\nb\nc\nd\u{2409}e\u{2407}\u{2421}"); + assert_eq!(dialogs::shown_text("a\nb", false), "a\u{240a}b", "a title stays on one line"); + assert_eq!(dialogs::cut_to("aé", 2), "a", "a cut keeps whole characters"); +} + +#[test] +fn dialog_transcript_on_success_and_failure() { + let mut s = one_doc(); + let r = headless(&mut s, "alert('one'); throw new Error('later');", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap_err(); + assert!(r.to_string().contains("later")); + let report = s.script_report().unwrap(); + assert_eq!(report["dialogs"], json!([{"kind": "alert", "message": "one", "title": "Script Alert", "errorIcon": false, "answer": "ok"}])); + let ok = headless(&mut s, "1", json!([])).unwrap(); + assert_eq!((ok["dialogs"].clone(), ok["dialogCount"].clone()), (json!([]), json!(0))); +} + +#[test] +fn dialog_wait_does_not_spend_fuel() { + let mut s = one_doc(); + framed(&mut s, "alert('x'); 1"); + for _ in 0..20 { + assert!(s.script_pump().is_none(), "the run waits"); + } + assert!(s.script_answer(DialogAnswer::Ok)); + let r = pump(&mut s).unwrap().unwrap(); + let mut t = one_doc(); + let plain = headless(&mut t, "alert('x'); 1", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap(); + assert_eq!((r["fuelUsed"].clone(), r["hostCalls"].clone()), (plain["fuelUsed"].clone(), plain["hostCalls"].clone()), "{r}"); +} + +#[test] +fn second_run_is_busy_while_dialog_waits() { + let mut s = one_doc(); + framed(&mut s, "alert('x');"); + assert!(s.execute("script.run", &json!({"source": "1"})).is_err()); + assert!(s.script_dialog().is_some(), "the first run still waits"); + assert!(s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 1, "height": 1})).is_err(), "edits are refused while it waits"); +} + +#[test] +fn a_dialog_in_a_callback_throws_in_the_app() { + let mut s = one_doc(); + s.script_frames = true; + let source = "var m = ''; [1].forEach(function () { try { alert('x'); } catch (e) { m = e.message; } }); vc.log(m);"; + s.execute("script.run", &json!({"source": source})).unwrap(); + let r = pump(&mut s).unwrap().unwrap(); + assert!(r["log"][0].as_str().unwrap().contains("callback"), "{r}"); + assert_eq!(r["dialogCount"], 0); +} diff --git a/crates/engine/src/tests/script_include.rs b/crates/engine/src/tests/script_include.rs index 2d857b230..945e6ca16 100644 --- a/crates/engine/src/tests/script_include.rs +++ b/crates/engine/src/tests/script_include.rs @@ -332,3 +332,59 @@ fn script_included_error_has_local_file_and_line() { assert!(error.starts_with("main.jsx: ") && error.contains("lib.jsxinc:3:5: Error: boom"), "{error}"); let _ = std::fs::remove_dir_all(base); } + +/// Pumps the run in the app until it ends or waits at a dialog (`None`). +fn pump(s: &mut Session) -> Option> { + for _ in 0..10_000 { + if s.script_dialog().is_some() { + return None; + } + if let Some(r) = s.script_pump() { + return Some(r); + } + } + panic!("the run neither ended nor waited"); +} + +#[test] +fn script_dialogs_in_included_and_evaluated_files() { + let base = folder("dialogs"); + write(&base.join("main/ask.jsxinc"), "var a = confirm('inc');\n"); + write(&base.join("main/ev.jsx"), "alert('ev'); 7"); + let text = "#include \"ask.jsxinc\"\nvar m = '';\ntry { m = $.evalFile('ev.jsx'); } catch (e) { m = e.message; }\nvc.log([a, m]);"; + let main = base.join("main/main.jsx"); + write(&main, text); + let path = main.to_string_lossy().to_string(); + // In the app, an included file's dialog waits as the script's own does; one in a file + // $.evalFile runs cannot wait there and throws an error the script can catch. + let mut s = Session::new(); + s.script_frames = true; + assert_eq!(s.execute("script.run", &json!({"path": path})).unwrap()["running"], true); + assert!(pump(&mut s).is_none()); + assert_eq!(s.script_dialog().unwrap().message, "inc"); + assert!(s.script_answer(crate::script::DialogAnswer::Confirm(true))); + let r = pump(&mut s).unwrap().unwrap(); + let m = r["log"][0][1].as_str().unwrap_or_default(); + assert_eq!(r["log"][0][0], true, "{r}"); + assert!(m.contains("alert") && m.contains("$.evalFile"), "the error names the call and why: {r}"); + assert_eq!(r["dialogCount"], 1, "{r}"); + // A run with nobody to ask takes both answers in order. + let mut s = Session::new(); + let answers = json!([{"kind": "confirm", "value": false}, {"kind": "alert", "value": "ok"}]); + let r = s.execute("script.run", &json!({"path": path, "answers": answers})).unwrap(); + assert_eq!((r["log"].clone(), r["dialogCount"].clone()), (json!([[false, 7]]), json!(2)), "{r}"); + // An error in an included file after its dialog is at that file's line, and the report keeps + // both the file and the dialog. + write(&base.join("main/ask.jsxinc"), "var a = confirm('inc');\nif (a) throw new Error('after');\n"); + let mut s = Session::new(); + s.script_frames = true; + s.execute("script.run", &json!({"path": path})).unwrap(); + assert!(pump(&mut s).is_none()); + assert!(s.script_answer(crate::script::DialogAnswer::Confirm(true))); + assert!(pump(&mut s).unwrap().is_err()); + let report = s.script_report().unwrap(); + assert_eq!(report["line"], 2, "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("ask.jsxinc"), "{report}"); + assert_eq!(report["dialogs"][0]["answer"], true, "{report}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index 43838ff8e..5124fb450 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -41,8 +41,9 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() let mut s = session(); let list = run(&mut s, "graphicStyle.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); - assert!(libs.len() >= 7 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}"); + assert!(libs.len() >= 8 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}"); + assert!(libs.iter().any(|l| l["id"] == "status-effects" && l["name"] == "Status Effects"), "{libs:?}"); for l in libs { let id = l["id"].as_str().unwrap(); let g = run(&mut s, "graphicStyle.library", json!({"library": id})); @@ -60,6 +61,9 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() let game = run(&mut s, "graphicStyle.library", json!({"library": "game-hud"})); let game_names: Vec<&str> = game["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); assert_eq!(game_names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]); + let status = run(&mut s, "graphicStyle.library", json!({"library": "status-effects"})); + let status_names: Vec<&str> = status["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]})); assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow"))); @@ -67,6 +71,12 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() assert_eq!(node.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex("#3498db").unwrap())); assert_eq!(node.appearance.stroke_paint(), Paint::None); assert_eq!(node.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.outerGlow"]); + let id2 = rect(&mut s, 60.0, 20.0, 40.0, 30.0); + let r2 = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "status-effects", "name": "Poison Tint", "apply": true, "ids": [id2.0]})); + assert_eq!((names(&r2["added"]), r2["applied"].as_str()), (vec!["Poison Tint"], Some("Poison Tint"))); + let node2 = doc(&s).node(id2).unwrap(); + assert_eq!(node2.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex("#2ecc71").unwrap())); + assert_eq!(node2.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.outerGlow"]); // By name in any case. assert_eq!(run(&mut s, "graphicStyle.library", json!({"library": "shadows and glows"}))["id"], "shadows-glows"); assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err()); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 424f30cdd..f729ed394 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -663,6 +663,26 @@ proptest! { survive(&svg, || vectorcraft_svg::import(&svg).ok())?; } + /// Clip paths made of text, of `` elements showing text, and of both with shapes, each with + /// hostile attributes, clipping art that may clip by them in turn. + #[test] + fn svg_text_clip_paths_never_panic( + text in prop::collection::vec(arb_svg_attr(), 0..5), + used in prop::collection::vec(arb_svg_attr(), 0..5), + clip in prop::collection::vec(arb_svg_attr(), 0..3), + body in prop::collection::vec(arb_svg_element(1), 0..3), + ) { + let svg = format!( + "abcClipit{}", + text.concat(), + clip.concat(), + text.concat(), + used.concat(), + body.concat() + ); + survive(&svg, || vectorcraft_svg::import(&svg).ok())?; + } + #[test] fn svg_mutated_export_never_panics( cut in 0usize..20_000, diff --git a/crates/engine/tests/it/dimensions.rs b/crates/engine/tests/it/dimensions.rs index 934108905..56ee67be1 100644 --- a/crates/engine/tests/it/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -1164,16 +1164,10 @@ fn an_expand_that_changes_no_art_leaves_a_dimension_live() { assert_eq!(art(&s, id), live); } -/// A dimension selected with the art it measures, or held by a selected group, expands the same -/// way: outlining that art does not remove it. +/// A dimension held by a selected group without the art it measures expands as a dimension +/// selected on its own does. #[test] -fn a_dimension_expanded_with_its_art_or_in_a_group_stays_plain_art() { - let mut s = session(); - let line = edge(&mut s); - let id = dim(&mut s, line); - run(&mut s, "select.set", json!({"ids":[line, id]})); - run(&mut s, "object.expand", json!({})); - stays_plain_art(&mut s, id); +fn a_dimension_expanded_in_a_group_without_its_art_stays_plain_art() { let mut s = session(); let line = edge(&mut s); let id = dim(&mut s, line); @@ -1183,8 +1177,176 @@ fn a_dimension_expanded_with_its_art_or_in_a_group_stays_plain_art() { stays_plain_art(&mut s, id); } -/// Expand replacing only the art a selected dimension measures leaves the dimension's art as it -/// is, without a link to the art that went. +/// `command` on a dimension selected with the art it measures, or on a group holding both, leaves +/// the dimension's own lines and label as they are and replaces only the art: the dimension stays +/// where it was, no longer linked, and one undo links it again. +fn keeps_its_own_lines_and_label(command: &str) { + for grouped in [false, true] { + let context = format!("{command}, grouped: {grouped}"); + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"})); + run(&mut s, "select.set", json!({"ids":[art_id]})); + run(&mut s, "appearance.addStroke", json!({})); + let id = dim(&mut s, art_id); + let live = art(&s, id); + let label = text(&s, id); + let at = bounds(&s, id); + let linked = recipe(&s, id); + let measured = s.doc().unwrap().doc.node(NodeId(art_id)).unwrap().clone(); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + if grouped { + run(&mut s, "object.group", json!({})); + } + let steps = s.doc().unwrap().history.undo.len(); + let result = run(&mut s, command, json!({})); + if command == "effect.expandAppearance" { + let mut own = vec![]; + s.doc().unwrap().doc.node(NodeId(id)).unwrap().walk(&mut |c| own.push(json!(c.id.0))); + assert!(result["ids"].as_array().unwrap().iter().all(|i| !own.contains(i)), "{context}: {result}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "{context}: one undo step"); + assert_ne!(s.doc().unwrap().doc.node(NodeId(art_id)), Some(&measured), "{context}: the art is replaced"); + assert_eq!(art(&s, id), live, "{context}: its own lines and label"); + assert_eq!(bounds(&s, id), at, "{context}: where it was"); + stays_unlinked(&mut s, id, &label, &context); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "{context}"); + assert_eq!(recipe(&s, id), linked, "{context}: one undo links it again"); + assert_eq!(s.doc().unwrap().doc.node(NodeId(art_id)), Some(&measured), "{context}"); + } +} + +#[test] +fn expand_leaves_a_dimension_selected_with_its_art_as_it_is() { + keeps_its_own_lines_and_label("object.expand"); +} + +#[test] +fn expand_appearance_leaves_a_dimension_selected_with_its_art_as_it_is() { + keeps_its_own_lines_and_label("effect.expandAppearance"); +} + +#[test] +fn outline_stroke_leaves_a_dimension_selected_with_its_art_as_it_is() { + keeps_its_own_lines_and_label("object.path.outlineStroke"); +} + +/// A selected group whose blur Expand Appearance turns into an image takes the dimension it holds, +/// with the art it measures, into that image. +#[test] +fn a_blurred_group_expanded_takes_its_dimension_into_the_image() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + let id = dim(&mut s, art_id); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + let group = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[group]})); + run(&mut s, "effect.apply", json!({"effect":"blur.gaussian"})); + run(&mut s, "effect.expandAppearance", json!({})); + let d = &s.doc().unwrap().doc; + assert!(d.node(NodeId(id)).is_none(), "the dimension is part of the image"); + let mut images = 0; + d.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_)))); + assert_eq!(images, 1); +} + +/// Object › Expand keeps a dimension selected with its art in the export assets that list it and +/// bound to its variables. +#[test] +fn expand_keeps_a_dimension_in_its_asset_and_its_variable_binding() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"})); + let id = dim(&mut s, art_id); + let asset = run(&mut s, "assets.add", json!({"ids":[id]}))["assets"][0].as_u64().unwrap(); + run(&mut s, "variable.define", json!({"name":"Shown","kind":"visibility"})); + run(&mut s, "variable.bind", json!({"variable":"Shown","ids":[id]})); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + run(&mut s, "object.expand", json!({})); + let d = &s.doc().unwrap().doc; + assert!(d.node(NodeId(id)).is_some_and(|n| n.extras().dimension.is_some()), "still a dimension"); + let listed = d.assets.iter().find(|a| a.id == asset).map(|a| a.nodes.clone()); + assert_eq!(listed, Some(vec![NodeId(id)]), "in its asset"); + assert!(d.variables.bindings.get(&NodeId(id)).is_some_and(|b| b.contains("Shown")), "bound to its variable"); +} + +/// A selected group with a geometry effect of its own bakes that effect into everything it holds +/// when its appearance expands, the dimension's lines and label included: the dimension becomes a +/// plain group of that art, as one selected without its art does. +#[test] +fn a_group_effect_expanded_reaches_the_dimension_it_holds() { + for command in ["effect.expandAppearance", "object.expand"] { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + let id = dim(&mut s, art_id); + let live = art(&s, id); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + let group = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[group]})); + run(&mut s, "effect.apply", json!({"effect":"distort.puckerBloat"})); + run(&mut s, command, json!({})); + let d = &s.doc().unwrap().doc; + let n = d.node(NodeId(id)).unwrap_or_else(|| panic!("{command}: the dimension's group is still there")); + assert!(n.extras().dimension.is_none(), "{command}: a plain group"); + assert_ne!(art(&s, id), live, "{command}: its art is baked with the group's effect"); + } +} + +/// When the dimension selected with its art is all there is to change, the command fails and +/// changes nothing. +#[test] +fn a_dimension_selected_with_art_that_has_nothing_to_expand_changes_nothing() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[art_id]})); + run(&mut s, "paint.setStroke", json!({"none": true})); + // A plain path: Expand would make a live rectangle a path. + run(&mut s, "object.expand", json!({})); + let id = dim(&mut s, art_id); + for grouped in [false, true] { + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + if grouped { + run(&mut s, "object.group", json!({})); + } + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!(info["appearance"], json!(false), "grouped: {grouped}: Object › Expand… is not dimmed for the dimension's label"); + assert_eq!(info["stroke"], json!(false), "grouped: {grouped}: {info}"); + assert!(!vectorcraft_engine::cmd::expand::has_appearance(&s), "grouped: {grouped}: the menus read this"); + for (command, p) in [ + ("object.expand", json!({})), + ("object.expand", json!({"object":false,"fill":false})), + ("object.path.outlineStroke", json!({})), + ("effect.expandAppearance", json!({})), + ] { + assert!(s.execute(command, &p).is_err(), "grouped: {grouped}: {command} {p}"); + assert_eq!(s.doc().unwrap().doc.layers, before.layers, "grouped: {grouped}: {command} {p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "grouped: {grouped}: {command} {p}"); + } + } +} + +/// The dimension `id` is a dimension that measures nothing, still showing `label`, and a move +/// takes it along without redrawing it from other art. +fn stays_unlinked(s: &mut Session, id: u64, label: &str, context: &str) { + let live = art(s, id); + assert!(live.2, "{context}: still a dimension"); + assert!(recipe(s, id).source.is_none(), "{context}: no longer linked"); + assert_eq!(text(s, id), label, "{context}"); + let at = bounds(s, id); + run(s, "select.set", json!({"ids":[id]})); + run(s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(s, id), live, "{context}"); + assert_eq!(text(s, id), label, "{context}"); + let moved = bounds(s, id); + assert!((moved[0] - at[0] - 30.0).abs() < 1e-6 && (moved[1] - at[1]).abs() < 1e-6, "{context}: {at:?} moved to {moved:?}"); + run(s, "edit.undo", json!({})); +} + +/// Expand replacing only the art a selected dimension measures leaves the dimension where it was, +/// still a dimension, without a link to the art that went; undo links it again. #[test] fn a_dimension_keeps_no_link_to_the_art_expand_replaced() { for circle in [false, true] { @@ -1197,18 +1359,21 @@ fn a_dimension_keeps_no_link_to_the_art_expand_replaced() { } else { dim(&mut s, art_id) }; - let (nodes, labels, _) = art(&s, id); + let live = art(&s, id); + let label = text(&s, id); run(&mut s, "select.set", json!({"ids":[art_id, id]})); run(&mut s, "object.expand", json!({"object":false,"stroke":false})); - assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}"); - run(&mut s, "select.set", json!({"ids":[id]})); - run(&mut s, "object.move", json!({"dx":30,"dy":0})); - assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}"); + stays_unlinked(&mut s, id, &label, &format!("circle: {circle}")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "circle: {circle}"); + assert!(recipe(&s, id).source.is_some(), "circle: {circle}: one undo links it again"); } } -/// Outline Stroke and Expand Appearance replacing the art a selected dimension measures leave the -/// dimension as a plain group, whether or not they change its own art. +/// Outline Stroke and Expand Appearance replacing only the art a selected dimension measures +/// leave the dimension where it was, still a dimension, no longer linked; undo links it again. +/// Here the dimension's lines are guides, which neither command changes, and the selection holds +/// the whole dimension or one of its lines. #[test] fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() { for command in ["object.path.outlineStroke", "effect.expandAppearance"] { @@ -1225,19 +1390,44 @@ fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() { }; run(&mut s, "select.set", json!({"ids":lines})); run(&mut s, "view.guides.make", json!({})); + let live = art(&s, id); + let label = text(&s, id); let picked = if whole { id } else { lines[0] }; run(&mut s, "select.set", json!({"ids":[art_id, picked]})); run(&mut s, command, json!({})); assert!(s.doc().unwrap().doc.node(NodeId(id)).is_some(), "{command} removed the dimension, whole: {whole}"); - let plain = art(&s, id); - assert!(!plain.2, "{command}: no recipe left, whole: {whole}"); - run(&mut s, "select.set", json!({"ids":[id]})); - run(&mut s, "object.move", json!({"dx":30,"dy":0})); - assert_eq!(art(&s, id), plain, "{command}, whole: {whole}"); + stays_unlinked(&mut s, id, &label, &format!("{command}, whole: {whole}")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "{command}, whole: {whole}"); + assert!(recipe(&s, id).source.is_some(), "{command}, whole: {whole}: one undo links it again"); } } } +/// Object › Expand that outlines only the stroke of the art a selected dimension measures leaves +/// the dimension a dimension, no longer linked, as Outline Stroke does. +#[test] +fn a_dimension_outlasts_the_stroke_expand_outlined() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"})); + let id = dim(&mut s, art_id); + let lines: Vec = { + let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); + children.iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).map(|c| c.id.0).collect() + }; + run(&mut s, "select.set", json!({"ids":lines})); + run(&mut s, "view.guides.make", json!({})); + let live = art(&s, id); + let label = text(&s, id); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + run(&mut s, "object.expand", json!({"object":false,"fill":false})); + stays_unlinked(&mut s, id, &label, "object.expand stroke"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live); + assert!(recipe(&s, id).source.is_some(), "one undo links it again"); +} + /// A part of a dimension expanded on its own ends the link too, so the move does not undo it. #[test] fn expanding_a_part_of_a_dimension_ends_its_link() { @@ -1271,10 +1461,11 @@ fn a_dimension_in_an_expanded_symbol_instance_stays_plain_art() { stays_plain_art(&mut s, root); } -/// Create Outlines on a dimension, or on its label alone, ends the link in the same undo step. +/// Create Outlines on a dimension, also one selected with the art it measures, or on its label +/// alone, ends the link in the same undo step. #[test] fn outlining_a_dimension_or_its_label_ends_its_link() { - for label_alone in [false, true] { + for target in ["dimension", "label", "with its art"] { let mut s = session(); let line = edge(&mut s); let id = dim(&mut s, line); @@ -1283,8 +1474,12 @@ fn outlining_a_dimension_or_its_label_ends_its_link() { let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); children.iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().id }; - let target = if label_alone { label.0 } else { id }; - run(&mut s, "select.set", json!({"ids":[target]})); + let ids = match target { + "label" => vec![label.0], + "with its art" => vec![line, id], + _ => vec![id], + }; + run(&mut s, "select.set", json!({"ids":ids})); run(&mut s, "type.createOutlines", json!({})); stays_plain_art(&mut s, id); run(&mut s, "edit.undo", json!({})); diff --git a/crates/engine/tests/plugin_safe_mode.rs b/crates/engine/tests/plugin_safe_mode.rs index 878ec6bf5..2c5fc1c30 100644 --- a/crates/engine/tests/plugin_safe_mode.rs +++ b/crates/engine/tests/plugin_safe_mode.rs @@ -462,3 +462,43 @@ fn expand_appearance_keeps_a_plug_in_effect_safe_mode_skips() { safe_mode::set_active(Some(safe_mode::Source::Flag)); s.execute("effect.expandAppearance", &json!({})).unwrap(); } + +/// In Safe Mode, a plug-in effect on a dimension selected with the art it measures does not stop +/// Expand Appearance: the dimension is left as it is, its effect included, and the art expands. +#[test] +fn expand_appearance_in_safe_mode_leaves_a_dimension_selected_with_its_art() { + let _f = fresh(); + let mut s = Session::new(); + let fx = wat::parse_str(tpl::returning(&tpl::manifest("test.sm.dim", "effect", "{}"), r#"{"objects":[]}"#)).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&fx)})).unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let art = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 72, "height": 72})).unwrap()["id"].clone(); + s.execute("paint.setStroke", &json!({"ids": [art], "color": "#000000"})).unwrap(); + s.execute("appearance.addStroke", &json!({})).unwrap(); + let source = json!({"kind": "segment", "edge": {"node": art, "subpath": 0, "segment": 0}}); + let dim = s.execute("dimension.create", &json!({"source": source})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [dim]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "plugin.test.sm.dim"})).unwrap(); + s.execute("select.set", &json!({"ids": [art, dim]})).unwrap(); + let node = |s: &mut Session, id: &Value| { + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + let mut found = Value::Null; + let mut stack = vec![inspect["layers"].clone()]; + while let Some(v) = stack.pop() { + match v { + Value::Array(a) => stack.extend(a), + Value::Object(o) if o.get("id") == Some(id) => found = Value::Object(o), + Value::Object(o) => stack.extend(o.into_iter().map(|(_, v)| v)), + _ => {} + } + } + found + }; + let kept = node(&mut s, &dim); + let measured = node(&mut s, &art); + safe_mode::set_active(Some(safe_mode::Source::Flag)); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + safe_mode::set_active(None); + assert_ne!(node(&mut s, &art), measured, "the art expands"); + assert_eq!(node(&mut s, &dim), kept, "the dimension and its effect stay as they are"); +} diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index b229ce6e6..b2ae916e6 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -175,15 +175,15 @@ pub(crate) enum NativeKind { /// What a function object runs. `Bound` is a function made by `Function.prototype.bind` (section /// 15.3.4.5): calling it calls `target` with `this` and `args` before the arguments it is given. -/// `Data` is a host function given [`crate::Data`]; with `text`, each argument arrives as the -/// string `String(value)` gives. `Eval` is a host function that gives source to run as global code -/// ([`crate::HostItem::Evaluator`]); `end` is called when that code ends. +/// `Data` is a host function given [`crate::Data`], its arguments read as `read` says. `Eval` is a +/// host function that gives source to run as global code ([`crate::HostItem::Evaluator`]); `end` +/// is called when that code ends. #[derive(Clone)] pub(crate) enum Func { Closure { code: Rc, env: EnvId }, Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str }, Host { f: Rc, name: Rc }, - Data { f: DataFn, name: Rc, text: bool }, + Data { f: DataFn, name: Rc, read: crate::host::ArgRead }, Eval { f: DataFn, end: Rc, name: Rc }, Bound { target: ObjId, this: Value, args: Rc<[Value]> }, } diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index b725245ce..997826f3e 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -59,11 +59,38 @@ pub enum HostError { Syntax(String), /// Ends the run with [`LimitKind::Host`] and this message. The script cannot catch it. Abort(String), + /// Pauses the run at this call: [`crate::Realm::advance`] returns [`crate::Progress::Paused`], + /// and the call's result is the value [`crate::Realm::answer`] gives it. A function may return + /// it only while [`crate::Realm::wait_flag`] is set; otherwise the run ends with an internal + /// error. + Wait, } /// A function the host gives a script, with its arguments converted. pub type HostFunction = Rc) -> Result>; +/// How one argument of a [`HostItem::Converted`] function is read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Arg { + /// The string `String(value)` gives. + Text, + /// `undefined` as [`Data::Undefined`], any other value as the string `String(value)` gives. + TextOrUndefined, + /// The boolean `Boolean(value)` gives. + Bool, +} + +/// How the arguments of a host function are read. +#[derive(Clone)] +pub(crate) enum ArgRead { + /// Each as [`Data`]. + Data, + /// Each as the string `String(value)` gives. + Text, + /// One by one as the list says; those past its end are not read. + Each(Rc<[Arg]>), +} + /// A member of an object the host adds to the global environment. #[derive(Clone)] pub enum HostItem { @@ -72,6 +99,9 @@ pub enum HostItem { Function(HostFunction), /// A function whose arguments arrive as strings, each the string `String(value)` gives. TextFunction(HostFunction), + /// A function whose arguments arrive converted one by one as the list says. Arguments past the + /// end of the list are not read and do not reach the function. + Converted(Vec, HostFunction), /// A property whose value the function gives each time it is read. Getter(HostFunction), /// A frozen object with these members. @@ -289,12 +319,23 @@ impl Interp { Ok(Value::Object(id)) } - /// Calls host function `f`: reads its arguments (with `text`, each as the string `String(value)` - /// gives), runs it, and gives its result to the script. - pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc, args: &[Value], text: bool) -> JsResult { - let data = self.read_args(name, args, text)?; - match f(data) { + /// Calls host function `f`: reads its arguments as `how` says, runs it, and gives its result to + /// the script. + pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc, args: &[Value], how: &ArgRead) -> JsResult { + let data = self.read_args(name, args, how)?; + // Only the script's own code, in a run that can pause, can wait for a result. + let can_wait = self.in_slice && self.reentry == 0; + self.can_wait.set(can_wait); + let result = f(data); + self.can_wait.set(false); + match result { Ok(d) => self.write_data(d, 0, false), + Err(HostError::Wait) if can_wait => { + self.waiting = true; + self.pause.set(true); + Ok(Value::Undefined) + } + Err(HostError::Wait) => Err(Abrupt::Internal(format!("{name} waited for its result where the run cannot pause"))), Err(e) => Err(self.host_error(e)?), } } @@ -304,7 +345,7 @@ impl Interp { /// value. A result that is not a string is given to the script as it is. `end` is called once /// the source has run, normally or not. pub(crate) fn call_evaluator(&mut self, f: &crate::heap::DataFn, end: &Rc, name: &Rc, args: &[Value]) -> JsResult { - let data = self.read_args(name, args, false)?; + let data = self.read_args(name, args, &ArgRead::Data)?; let source = match f(data) { Ok(Data::String(source)) => source, Ok(d) => return self.write_data(d, 0, false), @@ -322,20 +363,31 @@ impl Interp { r } - /// The arguments of host function `name` as [`Data`] (with `text`, each as the string - /// `String(value)` gives). - fn read_args(&mut self, name: &Rc, args: &[Value], text: bool) -> JsResult> { + /// The arguments of host function `name` as [`Data`], read as `how` says. + fn read_args(&mut self, name: &Rc, args: &[Value], how: &ArgRead) -> JsResult> { let mut reading = Reading { limits: self.data_limits, bytes: 0, entries: 0, ancestors: Vec::new(), path: Vec::new(), function: name.clone() }; + let args = match how { + ArgRead::Each(list) => args.get(..list.len().min(args.len())).unwrap_or(args), + _ => args, + }; let mut data = Vec::with_capacity(args.len()); // A conversion can run the script's own `toString`, which may collect garbage. let mark = self.temp_roots.len(); - if text { + if !matches!(how, ArgRead::Data) { self.temp_roots.extend(args.iter().cloned()); } let mut read = Ok(()); for (i, a) in args.iter().enumerate() { reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX))); - let d = if text { self.read_text(a, &mut reading) } else { self.read_data(a, &mut reading) }; + let d = match how { + ArgRead::Data => self.read_data(a, &mut reading), + ArgRead::Text => self.read_text(a, &mut reading), + ArgRead::Each(list) => match list.get(i) { + Some(Arg::TextOrUndefined) if matches!(a, Value::Undefined) => Ok(Data::Undefined), + Some(Arg::Bool) => Ok(Data::Bool(a.truthy())), + _ => self.read_text(a, &mut reading), + }, + }; match d { Ok(d) => data.push(d), Err(e) => { @@ -350,9 +402,12 @@ impl Interp { Ok(data) } - /// What host error `e` throws, or the limit it ends the run with. + /// What host error `e` throws, or the limit it ends the run with. A function that waits + /// ([`HostError::Wait`]) where its caller does not handle the wait ends the run with an internal + /// error. fn host_error(&mut self, e: HostError) -> JsResult { match e { + HostError::Wait => Ok(Abrupt::Internal("a host function waited for its result where the run cannot pause".into())), HostError::Abort(message) => { self.host_abort = Some(message); Ok(Abrupt::Limit(LimitKind::Host)) @@ -407,10 +462,13 @@ impl Interp { let full = format!("{path}.{name}"); let prop = match item { HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE }, - HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, false)), flags: ENUMERABLE }, - HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, true)), flags: ENUMERABLE }, - HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, false)), set: None, flags: ENUMERABLE }, + HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Data)), set: None, flags: ENUMERABLE }, HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE }, + HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Data)), flags: ENUMERABLE }, + HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Text)), flags: ENUMERABLE }, + HostItem::Converted(list, f) => { + Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Each(list.into()))), flags: ENUMERABLE } + } HostItem::Evaluator { source, end } => { let proto = self.intrinsics.function_proto; let f = self.heap.alloc_object(Object::new(Class::Function(Func::Eval { f: source, end, name: full.into() }), Some(proto))); @@ -420,9 +478,17 @@ impl Interp { self.define(o, key(&name), prop) } - fn host_function(&mut self, f: HostFunction, name: &str, text: bool) -> ObjId { + /// Adds the global function `name` with its arguments converted as `args` says. A script may + /// replace or delete it, as it may a built-in function. + pub(crate) fn install_host_function(&mut self, name: &str, args: Vec, f: HostFunction) -> JsResult<()> { + let o = self.host_function(f, name, ArgRead::Each(args.into())); + let global = self.global; + self.define(global, key(name), Prop::Data { value: Value::Object(o), flags: crate::heap::HIDDEN }) + } + + fn host_function(&mut self, f: HostFunction, name: &str, read: ArgRead) -> ObjId { let proto = self.intrinsics.function_proto; - self.heap.alloc_object(Object::new(Class::Function(Func::Data { f, name: name.into(), text }), Some(proto))) + self.heap.alloc_object(Object::new(Class::Function(Func::Data { f, name: name.into(), read }), Some(proto))) } } diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 93875f6d1..3492cf653 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -277,6 +277,84 @@ fn advancing_without_a_run_is_an_error() { assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Internal, "no run to advance")); } +/// A realm with the global `ask(question)`, which waits for its answer where it can and throws an +/// `Error` where it cannot. → how many times it was called. +fn with_ask(r: &mut Realm) -> Rc> { + let calls: Rc> = Rc::default(); + let (count, can_wait) = (calls.clone(), r.wait_flag()); + let f: HostFunction = Rc::new(move |_| { + count.set(count.get() + 1); + if can_wait.get() { Err(HostError::Wait) } else { Err(HostError::Error("cannot wait here".into())) } + }); + r.define_host_function("ask", vec![Arg::Text], f).unwrap(); + calls +} + +#[test] +fn a_waiting_call_pauses_at_the_call_and_takes_its_answer_once() { + let mut r = realm(1_000_000); + let calls = with_ask(&mut r); + r.start("var a = ask('first'); var b = ask('second'); a + '|' + b").unwrap(); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert!(r.waits()); + let fuel = r.fuel_left(); + let e = r.advance(1_000).unwrap_err(); + assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Internal, "the run waits for a host function's result")); + assert_eq!(r.fuel_left(), fuel, "no fuel is spent while the call waits"); + r.answer(Data::String("one".into())).unwrap(); + assert!(!r.waits()); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert!(r.waits()); + r.answer(Data::Null).unwrap(); + assert_eq!(finish(&mut r, 1_000).0.unwrap(), JsValue::String("one|null".into())); + assert_eq!(calls.get(), 2, "each call ran once"); + let e = r.answer(Data::Null).unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Internal); +} + +#[test] +fn only_the_scripts_own_code_in_a_resumable_run_can_wait() { + let mut r = realm(1_000_000); + with_ask(&mut r); + let caught = "var out = []; [1].forEach(function () { try { ask('x'); } catch (e) { out.push(e.message); } }); \ + var o = {}; Object.defineProperty(o, 'g', {get: function () { try { ask('y'); } catch (e) { out.push(e.message); } }}); o.g; \ + try { String({toString: function () { return ask('z'); }}); } catch (e) { out.push(e.message); } out.join()"; + r.start(caught).unwrap(); + assert_eq!(finish(&mut r, 1_000).0.unwrap(), JsValue::String("cannot wait here,cannot wait here,cannot wait here".into())); + let e = r.run("ask('q')").unwrap_err(); + assert!(e.message.contains("cannot wait here"), "a run that cannot pause cannot wait: {}", e.message); + r.start("var f = ask; ask.call(null, 'q'); f.apply(null, ['r'])").unwrap(); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert!(r.waits(), "call and apply from the script's own code wait"); +} + +#[test] +fn a_host_function_that_waits_where_it_cannot_ends_the_run() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|_| Err(HostError::Wait)); + r.define_host_function("ask", vec![], f).unwrap(); + let e = r.run("try { ask(); } catch (e) { 'caught' }").unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Internal); +} + +#[test] +fn a_converted_function_reads_each_argument_as_its_list_says() { + let mut r = realm(1_000_000); + let seen: Rc>>> = Rc::default(); + let log = seen.clone(); + let f: HostFunction = Rc::new(move |args| { + log.borrow_mut().push(args); + Ok(Data::Undefined) + }); + r.define_host_function("conv", vec![Arg::Text, Arg::TextOrUndefined, Arg::Bool], f).unwrap(); + r.run("conv(42, undefined, 'x', function () {}); conv(undefined, null, 0); conv({toString: function () { return 'own'; }}); conv()").unwrap(); + let s = |t: &str| Data::String(t.into()); + let want = vec![vec![s("42"), Data::Undefined, Data::Bool(true)], vec![s("undefined"), s("null"), Data::Bool(false)], vec![s("own")], vec![]]; + assert_eq!(*seen.borrow(), want, "arguments past the list are not read"); + let out = r.run("var a = typeof conv; conv = 1; a + ' ' + conv").unwrap(); + assert_eq!(out, JsValue::String("function 1".into()), "a script may replace the function"); +} + proptest::proptest! { /// A slice size does not change what a script computes. #[test] diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 22bbb6c0d..6c413e6d7 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -181,6 +181,12 @@ pub(crate) struct Interp { pub(crate) held: Rc, /// Set by the host to end the current slice of a resumable run at the next safe point. pub(crate) pause: Rc>, + /// The outermost loop of a resumable run is running ([`crate::Realm::advance`]). + pub(crate) in_slice: bool, + /// True while a host function runs where it may wait ([`crate::HostError::Wait`]). + pub(crate) can_wait: Rc>, + /// A host function's call waits for its result: the value on top of the stack stands in for it. + pub(crate) waiting: bool, /// The message of the uncatchable error a host function ended the run with. pub(crate) host_abort: Option, /// How much a value passed to or from a host function may carry. @@ -380,6 +386,9 @@ impl Interp { date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), pause: Rc::default(), + in_slice: false, + can_wait: Rc::default(), + waiting: false, host_abort: None, data_limits: crate::DataLimits::default(), source_tag: crate::realm::UNKNOWN_SOURCE, @@ -423,7 +432,7 @@ 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(()) } @@ -1855,10 +1864,10 @@ impl Interp { self.temp_roots.truncate(mark); return Ok(Prepared::Done(r?)); } - Func::Data { f, name, text } => { + Func::Data { f, name, read } => { // The arguments are read before the function runs, and a result being built // keeps itself reachable. - return Ok(Prepared::Done(self.call_data(&f, &name, &args, text)?)); + return Ok(Prepared::Done(self.call_data(&f, &name, &args, &read)?)); } Func::Eval { f, end, name } => return Ok(Prepared::Done(self.call_evaluator(&f, &end, &name, &args)?)), Func::Host { f: host, .. } => { diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 67ee2712c..3936154eb 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -66,7 +66,7 @@ pub use ast::{ PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, }; pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; -pub use host::{Data, DataLimits, HostError, HostFunction, HostItem}; +pub use host::{Arg, Data, DataLimits, HostError, HostFunction, HostItem}; pub use lexer::{Goal, Lexer}; pub use number::to_string as number_to_string; pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, UNKNOWN_SOURCE}; diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index beaad2557..9ab7623dc 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -222,6 +222,7 @@ impl Realm { interp.throw_site = None; interp.last_thrown = Value::Undefined; interp.host_abort = None; + interp.waiting = false; interp.error_tag = UNKNOWN_SOURCE; let strict = interp.options.strict; interp.source_tag = 0; @@ -247,6 +248,7 @@ impl Realm { interp.frames.clear(); interp.temp_roots.clear(); interp.join_stack.clear(); + interp.waiting = false; match result { Ok(v) => Ok(interp.to_js_value(&v)), Err(Abrupt::Throw(v)) => { @@ -276,16 +278,22 @@ impl Realm { /// instruction), and pauses where it can resume: between two instructions of the script's own /// code. A getter, a built-in's callback or another nested run has native frames under it, so /// it runs on to its end, for at most [`Realm::set_unbroken_fuel`] more. Once this has returned - /// [`Progress::Done`] or an error, the run is over. + /// [`Progress::Done`] or an error, the run is over. While a host function's call waits for its + /// result ([`Realm::waits`]), the run does not advance: this gives an internal error. pub fn advance(&mut self, slice: u64) -> Result { let interp = &mut self.interp; if interp.frames.is_empty() { return Err(RunError { kind: RunErrorKind::Internal, message: "no run to advance".into(), line: 0, column: 0 }); } + if interp.waiting { + return Err(RunError { kind: RunErrorKind::Internal, message: "the run waits for a host function's result".into(), line: 0, column: 0 }); + } let total = interp.fuel; let cap = slice.saturating_add(self.unbroken_fuel).min(total); interp.fuel = cap; + interp.in_slice = true; let r = interp.execute_slice(cap - slice.min(cap)); + interp.in_slice = false; let used = cap - interp.fuel; interp.fuel = total - used; match r { @@ -307,6 +315,38 @@ impl Realm { self.interp.pause.clone() } + /// The flag that is set while a host function runs where it may return + /// [`crate::HostError::Wait`]: called by the script's own code in a run [`Realm::advance`] + /// carries on, and not from a getter, a conversion or a built-in's callback. + pub fn wait_flag(&self) -> std::rc::Rc> { + self.interp.can_wait.clone() + } + + /// Does a host function's call wait for the result [`Realm::answer`] gives it? + pub fn waits(&self) -> bool { + self.interp.waiting + } + + /// Gives the call that waits ([`crate::HostError::Wait`]) its result `d`; the next + /// [`Realm::advance`] goes on from there. A result past the heap limit ends the run with that + /// limit, and with no call waiting this gives an internal error. + pub fn answer(&mut self, d: crate::Data) -> Result<(), RunError> { + let interp = &mut self.interp; + if !interp.waiting { + return Err(RunError { kind: RunErrorKind::Internal, message: "no host function waits for a result".into(), line: 0, column: 0 }); + } + interp.waiting = false; + // The value on top of the stack stood in for the result. + let r = interp.write_data(d, 0, false).and_then(|v| { + interp.stack.pop(); + interp.push(v) + }); + match r { + Ok(()) => Ok(()), + Err(e) => self.outcome(Err(e)).map(drop), + } + } + /// Has this realm stop when `interrupt` is set, in place of its own flag. pub fn share_interrupt(&mut self, interrupt: &Interrupt) { self.interp.interrupt = interrupt.0.clone(); @@ -339,6 +379,16 @@ impl Realm { } } + /// 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. + pub fn define_host_function(&mut self, name: &str, args: Vec, f: crate::HostFunction) -> Result<(), RunError> { + match self.interp.install_host_function(name, args, f) { + Ok(()) => Ok(()), + Err(e) => self.outcome(Err(e)).map(drop), + } + } + /// Defines the global variable `name` holding the exception the last run did not catch /// (`undefined` if there was none). pub fn bind_last_thrown(&mut self, name: &str) { diff --git a/crates/svg/src/import/convert.rs b/crates/svg/src/import/convert.rs index d8cf3bcd5..8bdadb031 100644 --- a/crates/svg/src/import/convert.rs +++ b/crates/svg/src/import/convert.rs @@ -192,49 +192,58 @@ const MAX_CLIP_DEPTH: usize = 16; /// multiply the work at every level: without this, a few lines of SVG would never finish. const MAX_CLIP_READS: usize = 1024; -/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document. -pub(super) fn clip_shapes(cp: &usvg::ClipPath, ts: Affine) -> Vec<(BezPath, FillRule)> { +/// The shapes a text placeholder path in a clip path stands for (the text's glyph outlines), given +/// the path and the transform `m` that maps its user space to the document; `None` for any other +/// path. +pub(super) type ClipText<'a> = &'a dyn Fn(&usvg::Path, Affine) -> Option>; + +/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document, with the +/// glyph outlines `text` gives for the text in it. +pub(super) fn clip_shapes(cp: &usvg::ClipPath, ts: Affine, text: ClipText) -> Vec<(BezPath, FillRule)> { let mut reads = MAX_CLIP_READS; - clip_shapes_depth(cp, ts, 0, &mut reads) + clip_shapes_depth(cp, ts, 0, &mut reads, text) } /// Recursively collect shapes for `cp` in document space up to `MAX_CLIP_DEPTH`, and while `reads` /// are left. -fn clip_shapes_depth(cp: &usvg::ClipPath, ts: Affine, depth: usize, reads: &mut usize) -> Vec<(BezPath, FillRule)> { +fn clip_shapes_depth(cp: &usvg::ClipPath, ts: Affine, depth: usize, reads: &mut usize, text: ClipText) -> Vec<(BezPath, FillRule)> { if depth > MAX_CLIP_DEPTH || *reads == 0 { return Vec::new(); } *reads -= 1; - fn collect(g: &usvg::Group, m: Affine, depth: usize, reads: &mut usize, out: &mut Vec<(BezPath, FillRule)>) { + fn collect(g: &usvg::Group, m: Affine, depth: usize, reads: &mut usize, text: ClipText, out: &mut Vec<(BezPath, FillRule)>) { for c in g.children() { match c { usvg::Node::Group(g) => { let m_child = m * aff(g.transform()); if let Some(child_cp) = g.clip_path() { let mut child_shapes = Vec::new(); - collect(g, m_child, depth + 1, reads, &mut child_shapes); + collect(g, m_child, depth + 1, reads, text, &mut child_shapes); if !child_shapes.is_empty() { - let clip_paths = clip_shapes_depth(child_cp, m_child, depth + 1, reads); + let clip_paths = clip_shapes_depth(child_cp, m_child, depth + 1, reads, text); intersect_shapes(&child_shapes, &clip_paths, out); } } else { - collect(g, m_child, depth, reads, out); + collect(g, m_child, depth, reads, text, out); } } - usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), + usvg::Node::Path(p) => match text(p, m) { + Some(glyphs) => out.extend(glyphs), + None => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), + }, _ => {} } } } let mut shapes = Vec::new(); - collect(cp.root(), ts * aff(cp.transform()), depth, reads, &mut shapes); + collect(cp.root(), ts * aff(cp.transform()), depth, reads, text, &mut shapes); if !shapes.is_empty() && depth > 0 && depth < MAX_CLIP_DEPTH && *reads > 0 && let Some(parent_cp) = cp.clip_path() { - let parent_shapes = clip_shapes_depth(parent_cp, ts, depth + 1, reads); + let parent_shapes = clip_shapes_depth(parent_cp, ts, depth + 1, reads, text); let mut intersected = Vec::new(); intersect_shapes(&shapes, &parent_shapes, &mut intersected); shapes = intersected; @@ -267,7 +276,7 @@ fn intersect_shapes(shapes: &[(BezPath, FillRule)], clips: &[(BezPath, FillRule) /// Does clip path `cp` (on an element whose user space `ts` maps to the document) leave `art`, /// placed by `xf`, whole? (Only its bounds are checked: the box must be inside a clip shape.) -pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> bool { +pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine, text: ClipText) -> bool { let Some(b) = art.visual_bounds() else { return false }; if cp.clip_path().is_some() { return false; @@ -275,7 +284,7 @@ pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> // Art touching the clip's edge isn't cut by it. let e = 1e-6 * (b.width() + b.height()).max(1e-9); let b = b.inflate(-e, -e); - let shapes = clip_shapes(cp, ts); + let shapes = clip_shapes(cp, ts, text); let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; corners.iter().all(|c| shapes.iter().any(|(s, _)| kurbo::Shape::contains(s, xf * *c))) } diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 6324e5380..56efe7b3a 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -194,7 +194,7 @@ impl Importer { _ => self.named("", NodeKind::Group { children: kids, clip: false })?, }; // usvg clips a `` of a symbol to its viewport: that only counts when it cuts the art. - let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf)); + let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf, &|p, m| self.clip_text(p, m))); let name = match self.symbols.get(&sym) { Some(made) if whole && made.art == without_ids(&art) && made.reuse.allows(xf) => made.name.clone(), Some(_) => return None, @@ -519,7 +519,10 @@ impl Importer { } fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { - let mut shapes = clip_shapes(cp, ts); + if let Some(n) = self.clip_type(cp, ts) { + return Some(n); + } + let mut shapes = clip_shapes(cp, ts, &|p, m| self.clip_text(p, m)); let id = cp.id().to_string(); if shapes.len() == 1 { let (bp, r) = shapes.pop()?; @@ -540,6 +543,53 @@ impl Importer { self.named(&id, NodeKind::Group { children, clip: false }) } + /// The glyph outlines, in the document, of the text a clip placeholder path `p` stands for (see + /// [`text`]), whose user space `m` maps to the document; nothing when the text is hidden. `None` + /// when `p` isn't a placeholder: in a clip path only its id tells (usvg paints clip path content + /// black). + fn clip_text(&self, p: &usvg::Path, m: Affine) -> Option> { + if p.id().is_empty() { + return None; + } + let i = self.slots.find(p)?; + let t = self.slots.texts.get(i)?; + if !p.is_visible() { + return Some(vec![]); + } + let mut bp = BezPath::clone(&*self.slots.outlines(i)?); + bp.apply_affine(m * self.slots.inner(p) * t.obj.xf); + let r = p.fill().map(|f| rule(f.rule())).unwrap_or_default(); + Some(if bp.elements().is_empty() { vec![] } else { vec![(bp, r)] }) + } + + /// Clip path `cp` (on an element whose user space `ts` maps to the document) as live type, when + /// all it holds is one visible text that clips by the non-zero rule and has no clip path of its + /// own: the text object with no fill or stroke, the same structure Make Clipping Mask produces + /// for type. + fn clip_type(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { + let mut m = ts * aff(cp.transform()); + let mut kids = cp.root().children(); + while let [usvg::Node::Group(g)] = kids + && g.clip_path().is_none() + { + m *= aff(g.transform()); + kids = g.children(); + } + let [usvg::Node::Path(p)] = kids else { return None }; + if p.id().is_empty() || !p.is_visible() || p.fill().is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd) { + return None; + } + let mut obj = self.slots.texts.get(self.slots.find(p)?)?.obj.clone(); + obj.xf = m * self.slots.inner(p) * obj.xf; + let grow = text::size_scale(&obj, obj.xf); + for run in &mut obj.runs { + run.style.fill = Paint::None; + run.style.stroke = Paint::None; + } + text::fold_scale(&mut obj, grow); + self.named(cp.id(), NodeKind::Text(Box::new(obj))) + } + /// One clip path child: a path, or a compound path of its subpaths when it has several. fn clip_shape(&mut self, bp: &BezPath, r: FillRule, clipping: bool) -> Option { let subs = PathData::from_bezpath(bp).subpaths; diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 2502bd2bd..904b76cd1 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -10,6 +10,13 @@ //! gradients and patterns in the text's user space; the importer swaps each placeholder for the //! [`PendingText`] it stands for. //! +//! usvg accepts only shapes, `` and `` as the children of a ``, so a `` +//! there (and a `` there that shows one) becomes a placeholder path instead, which the importer +//! finds by its id (`{prefix}{i}-…`). The importer clips by the text's glyph outlines, or uses the +//! text itself as live type. A `` in a clip path that has an id also gets the usual +//! placeholder group after its path, and a `` there with an id keeps a copy in a `` +//! after its path, for `` elements outside the clip path. +//! //! Characters keep SVG's per-character `x`/`y`/`dx`/`dy`/`rotate` lists: a character with an //! absolute `x` that moves down by at least half a line starts a new line (the leading becomes the //! line spacing), other vertical moves become baseline shift, horizontal moves become kerning (an @@ -166,14 +173,21 @@ pub(super) struct PendingText { pub(super) struct TextSlots { prefix: String, pub texts: Vec, + /// The transform between a clip placeholder path's user space and its text's, by placeholder + /// id, where the placeholder leaves it out (see [`clip_uses`]). + inner: HashMap, + /// The glyph outlines of each text laid out so far, in text space, by index. + outlines: RefCell>>, } impl TextSlots { - /// The text a placeholder's main path stands for: by its id, or inside `` instances - /// (where usvg drops ids) by its colour (the index) and its rectangle. + /// The text a placeholder's main path stands for: by its id (`{prefix}{index}`, or + /// `{prefix}{index}-{k}` for the clip placeholders), or inside `` instances (where usvg + /// drops ids) by its colour (the index) and its rectangle. pub fn find(&self, p: &usvg::Path) -> Option { if !p.id().is_empty() { - return p.id().strip_prefix(&self.prefix)?.parse().ok(); + let rest = p.id().strip_prefix(&self.prefix)?; + return rest.split_once('-').map_or(rest, |(i, _)| i).parse().ok(); } let Some(usvg::Paint::Color(c)) = p.fill().map(|f| f.paint()) else { return None }; if p.stroke().is_some() || p.data().segments().count() != 5 { @@ -185,6 +199,23 @@ impl TextSlots { let near = |a: f32, b: f64| (a as f64 - b).abs() <= 1e-3 * b.abs().max(1.0); (near(b.left(), m.x0) && near(b.top(), m.y0) && near(b.right(), m.x1) && near(b.bottom(), m.y1)).then_some(i) } + + /// The transform from the user space of clip placeholder path `p` to the space of the text it + /// stands for (the identity unless [`clip_uses`] left the text's transform out of it). + pub fn inner(&self, p: &usvg::Path) -> Affine { + self.inner.get(p.id()).copied().unwrap_or(Affine::IDENTITY) + } + + /// The glyph outlines of text `i` in text space, laid out once however many clips use it. + pub fn outlines(&self, i: usize) -> Option> { + if let Some(bp) = self.outlines.borrow().get(&i) { + return Some(bp.clone()); + } + let t = self.texts.get(i)?; + let bp = Rc::new(vectorcraft_text::layout(FontDb::global(), &t.obj).to_bezpath()); + self.outlines.borrow_mut().insert(i, bp.clone()); + Some(bp) + } } /// Replace every `` usvg would draw by a placeholder group (see the module docs). @@ -200,10 +231,11 @@ pub(super) fn prepare<'s>( while xml.descendants().any(|n| n.attribute("id").is_some_and(|id| id.starts_with(&prefix))) { prefix.insert(0, '_'); } - let mut slots = TextSlots { prefix, texts: vec![] }; + let mut slots = TextSlots { prefix, ..TextSlots::default() }; let style_xml = original_xml.unwrap_or(xml); let cx = Ctx::new(style_xml, dpi); - let mut out = String::new(); + // The replacements, in source order: each element's range and what takes its place. + let mut edits: Vec<(Range, String)> = vec![]; let mut copied = 0; let is_text = |n: &XNode| { n.is_element() @@ -211,52 +243,227 @@ pub(super) fn prepare<'s>( && n.tag_name().namespace() == Some(SVG_NS) && !n.ancestors().any(|a| a.has_tag_name("style")) }; + // The slot of each text, by element, for the `` elements in clip paths below. + let mut slot_of = HashMap::new(); for (t, style_t) in xml.descendants().filter(is_text).zip(style_xml.descendants().filter(is_text)) { let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); - if t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") { + // CSS Masking Module Level 1 §6.1 (https://www.w3.org/TR/css-masking-1/#ClipPathElement): + // text is clip path content only as a child of the ``. + let clip_child = in_clip_path(t); + if !clip_child && t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") { warnings.push(format!("{label} in a clip path ignored")); continue; } let r = t.range(); - // The element's qualified name (`text` or `svg:text`), so the placeholder lands in the same - // namespace; skip anything whose source range isn't the element itself (entity expansions). - let Some(qname) = svg - .get(r.start..r.end) - .and_then(|s| s.strip_prefix('<')) - .map(|s| s.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or("")) - else { - continue; - }; + let Some(q) = qualifier(svg, t) else { continue }; let flowed = t.tag_name().name() == "flowRoot"; - let Some(q) = qname.strip_suffix(t.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':')) else { continue }; if cx.css.own(style_t, "display").as_deref() == Some("none") || r.start < copied { continue; } let built = if flowed { cx.build_flow(style_t, &label, warnings) } else { cx.build(style_xml, style_t, &label, warnings) }; let Some((text, paints)) = built else { continue }; - out.push_str(&svg[copied..r.start]); - placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, slots.texts.len(), text.marker, &paints); + let mut out = String::new(); + let index = slots.texts.len(); + if clip_child { + let id = format!("{}{index}-c", slots.prefix); + let props = clip_props(&cx.css, Some(style_t), None); + clip_placeholder(&mut out, q, &id, &own_transform(&cx.css, style_t), &props, t, text.marker); + // A `` elsewhere can still show the text by its id: the clip path leaves out this + // group (a group isn't clip path content), but the `` finds it. + if t.attribute("id").is_some() { + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + } + } else { + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + } copied = r.end; + edits.push((r, out)); + slot_of.insert(t.id(), index); slots.texts.push(text); } if slots.texts.is_empty() { return (Cow::Borrowed(svg), slots); } - out.push_str(&svg[copied..]); + clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut edits, warnings); + edits.sort_by_key(|(r, _)| r.start); + let mut out = String::with_capacity(svg.len()); + let mut copied = 0; + for (r, s) in edits { + if r.start < copied { + continue; + } + out.push_str(svg.get(copied..r.start).unwrap_or("")); + out.push_str(&s); + copied = r.end; + } + out.push_str(svg.get(copied..).unwrap_or("")); (Cow::Owned(out), slots) } +/// Is `n` a child of a ``? +fn in_clip_path(n: XNode) -> bool { + n.parent_element().is_some_and(|p| p.tag_name().name() == "clipPath" && p.tag_name().namespace() == Some(SVG_NS)) +} + +/// The namespace prefix of element `n` as the source writes it (empty, or `svg:` and the like), so +/// what replaces it lands in the same namespace. `None` when its source range isn't the element +/// itself (entity expansions). +fn qualifier<'s>(svg: &'s str, n: XNode) -> Option<&'s str> { + let r = n.range(); + let qname = svg + .get(r.start..r.end) + .and_then(|s| s.strip_prefix('<')) + .map(|s| s.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""))?; + qname.strip_suffix(n.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':')) +} + +/// Replace each `` in a clip path that shows a text by the clip placeholder of that text, +/// placed by the `` (usvg drops ids and paints clip path content black, so the text a +/// placeholder group stands for couldn't be found again inside one). +#[allow(clippy::too_many_arguments)] +fn clip_uses( + svg: &str, + xml: &roxmltree::Document, + style_xml: &roxmltree::Document, + cx: &Ctx, + slot_of: &HashMap, + slots: &mut TextSlots, + edits: &mut Vec<(Range, String)>, + warnings: &mut Vec, +) { + let is_use = |n: &XNode| n.is_element() && n.tag_name().name() == "use" && n.tag_name().namespace() == Some(SVG_NS) && in_clip_path(*n); + if !xml.descendants().any(|n| is_use(&n)) { + return; + } + let (ids, style_ids) = (first_ids(xml), first_ids(style_xml)); + for (k, (u, style_u)) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)).enumerate() { + let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue }; + let Some(t) = ids.get(id) else { continue }; + let Some(&i) = slot_of.get(&t.id()) else { continue }; + let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue }; + let Some(q) = qualifier(svg, u) else { continue }; + // An undisplayed `` adds nothing to the clip (usvg leaves it out as it is). + if cx.css.own(style_u, "display").is_some_and(|d| d.trim() == "none") { + continue; + } + let style_t = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()); + let em = cx.font_size(style_u); + let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0); + let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1)); + let placed = format!("{} translate({x} {y})", own_transform(&cx.css, style_u)); + let text_xf = match style_t { + Some(s) => own_transform(&cx.css, s), + None => t.attribute("transform").unwrap_or("").to_string(), + }; + // One element can carry one clip path. The text's own clip path is in the text's user space + // and wins; a clip path of the `` is in the ``'s, so the text's transform moves + // to the importer instead. + let clipped = |n: XNode| cx.css.own(n, "clip-path").is_some_and(|v| v.trim() != "none"); + let id = format!("{}{i}-{k}", slots.prefix); + let transform = if style_t.is_some_and(clipped) { + if clipped(style_u) { + let label = u.attribute("id").map_or_else(|| "a use of a text".to_string(), |id| format!("'{id}'")); + warnings.push(format!("clip path of {label} in a clip path ignored: the text's own applies")); + } + format!("{placed} {text_xf}") + } else { + slots.inner.insert(id.clone(), parse_transform(Some(&text_xf))); + placed + }; + let props = clip_props(&cx.css, style_t, Some(style_u)); + let mut out = String::new(); + clip_placeholder(&mut out, q, &id, &transform, &props, u, marker); + // Another `` can still show this one by its id: a copy of it stays in a ``, which + // the clip path leaves out, with the properties style rules gave it written out. + if u.attribute("id").is_some() { + use_copy(&mut out, q, &cx.css, u, style_u); + } + edits.push((u.range(), out)); + } +} + +/// Write a copy of `` `u` in a ``: its attributes in no namespace, its link as `href`, +/// and the properties set on it by rules and its `style` (from `style_u`) as its `style`. +fn use_copy(out: &mut String, q: &str, css: &Styles, u: XNode, style_u: XNode) { + let _ = write!(out, "<{q}defs><{q}use"); + for a in u.attributes().filter(|a| a.namespace().is_none() && !matches!(a.name(), "style" | "href")) { + let _ = write!(out, " {}=\"{}\"", a.name(), xml_escape(a.value())); + } + if let Some(h) = href(u) { + let _ = write!(out, " href=\"{}\"", xml_escape(h)); + } + let declared = css.declared(style_u); + let mut props: Vec<_> = declared.iter().collect(); + props.sort(); + let style: String = props.into_iter().map(|(k, v)| format!("{k}:{v};")).collect(); + let _ = write!(out, " style=\"{}\"/>", xml_escape(&style)); +} + +/// The identity transform, as a `transform` value usvg reads. +const IDENTITY: &str = "matrix(1 0 0 1 0 0)"; + +/// The transform of element `n`: a `transform` declaration in a rule or its `style` (which the +/// prepass has written in SVG syntax), else its `transform` attribute; empty when it has none. +fn own_transform(css: &Styles, n: XNode) -> String { + css.own(n, "transform").filter(|v| !v.eq_ignore_ascii_case("none")).unwrap_or_default() +} + +/// Each id of `doc` with the first element that has it (a reference goes to that one, as in usvg). +fn first_ids<'a, 'i>(doc: &'a roxmltree::Document<'i>) -> HashMap<&'a str, XNode<'a, 'i>> { + let mut ids = HashMap::new(); + for n in doc.descendants() { + if let Some(id) = n.attribute("id") { + ids.entry(id).or_insert(n); + } + } + ids +} + +/// The properties of clip path content that the placeholder of text `t` keeps, as a `style` value: +/// its `clip-path` (or that of the `` `u` showing it, when the text has none), and its +/// computed `clip-rule` and `visibility`, inherited from `u` when there is one. Every value is +/// written out, so no style rule that matches the placeholder path applies to it. +fn clip_props(css: &Styles, t: Option, u: Option) -> String { + let set = |n: XNode, p: &str| css.own(n, p).filter(|v| v.trim() != "inherit"); + let clip = |n: XNode| css.own(n, "clip-path").filter(|v| v.trim() != "none"); + let mut style = format!("clip-path:{};", t.and_then(clip).or_else(|| u.and_then(clip)).unwrap_or_else(|| "none".into())); + for (p, initial) in [("clip-rule", "nonzero"), ("visibility", "visible")] { + let v = t.and_then(|t| set(t, p)).or_else(|| u.or(t).and_then(|n| css.prop(n, p))).unwrap_or_else(|| initial.into()); + let _ = write!(style, "{p}:{v};"); + } + style +} + +/// Write the placeholder of a text in a clip path: a path over the text's bounds `b` with id `id`, +/// transform `transform`, properties `props` ([`clip_props`]) and the conditions of element `n`. +/// usvg drops a group in a clip path and paints clip path content black whatever its fill, so the +/// placeholder is a path, and the importer finds it by its id ([`TextSlots::find`]). +fn clip_placeholder(out: &mut String, q: &str, id: &str, transform: &str, props: &str, n: XNode, b: Rect) { + let _ = write!(out, "<{q}path id=\"{}\"", xml_escape(id)); + for a in ["systemLanguage", "requiredFeatures", "requiredExtensions"] { + if let Some(v) = n.attribute(a) { + let _ = write!(out, " {a}=\"{}\"", xml_escape(v)); + } + } + // In `style`, so no rule that matches the path moves it. + let transform = if transform.trim().is_empty() { IDENTITY } else { transform }; + let _ = write!(out, " style=\"transform:{};{}display:inline\"", xml_escape(transform), xml_escape(props)); + let _ = write!(out, " d=\"M{} {}H{}V{}H{}Z\"/>", b.x0, b.y0, b.x1, b.y1, b.x0); +} + /// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`. #[allow(clippy::too_many_arguments)] fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) { let _ = write!(out, "<{q}g"); - for a in ["id", "transform", "systemLanguage", "requiredFeatures", "requiredExtensions"] { + for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"] { if let Some(v) = t.attribute(a) { let _ = write!(out, " {a}=\"{}\"", xml_escape(v)); } } - // The text's own non-inherited properties, overriding any rule that matches the ``. - let mut style = String::new(); + // The text's own non-inherited properties, overriding any rule that matches the ``. The + // transform too: a `transform` declaration in a rule or `style` beats the attribute. + let transform = own_transform(css, style_t); + let mut style = format!("transform:{};", if transform.is_empty() { IDENTITY } else { &transform }); for (p, initial) in [("opacity", "1"), ("clip-path", "none"), ("mask", "none"), ("filter", "none"), ("mix-blend-mode", "normal"), ("isolation", "auto")] { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a3ad7a069..8a447f6c3 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -78,8 +78,11 @@ //! 64 periods). //! * `` files are read through [`ImportOptions`]: rasters stay linked, SVG files (and SVG //! `data:` URLs) become art (without their text), missing ones a placeholder keeping the link. -//! * Text lines after the first start at the first line's x; text in a clip path is ignored; -//! absolute positions inside type on a path are ignored; vertical text sets every glyph sideways. +//! * Text lines after the first start at the first line's x; absolute positions inside type on a +//! path are ignored; vertical text sets every glyph sideways. +//! * A `` in a ``, or a `` there that shows one, clips by its glyph outlines. +//! When a clip path holds only one text, and that text has no clip path of its own and no +//! even-odd clip rule, the clip group's clipping path is the text as live type that paints nothing. //! //! ## Import text //! diff --git a/crates/svg/tests/it/clip_text.rs b/crates/svg/tests/it/clip_text.rs new file mode 100644 index 000000000..07773dd5f --- /dev/null +++ b/crates/svg/tests/it/clip_text.rs @@ -0,0 +1,429 @@ +//! SVG import of clip paths made of text (CSS Masking Module Level 1 §6.1, +//! https://www.w3.org/TR/css-masking-1/#ClipPathElement: `` and `` are clip path +//! content). + +use vectorcraft_color::Paint; +use vectorcraft_doc::{Document, Node, NodeKind, TextKind}; +use vectorcraft_geom::kurbo::Shape; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_svg::{ExportOptions, export, import, import_with_report}; + +fn svg(body: &str) -> String { + format!(r#"{body}"#) +} + +fn open(body: &str) -> Document { + import(&svg(body)).unwrap_or_else(|e| panic!("{e}")) +} + +/// The top-level objects of the first layer. +fn top(d: &Document) -> &[std::sync::Arc] { + d.layers[0].children().unwrap() +} + +/// The clip group `n` is, with its clipping path and the art it clips. +fn clip_group(n: &Node) -> (&Node, &[std::sync::Arc]) { + let NodeKind::Group { children, clip: true } = &n.kind else { panic!("not a clip group: {:?}", n.kind) }; + let (first, rest) = children.split_first().unwrap(); + (first, rest) +} + +/// The bounds of the region a clipping path clips to. +fn clip_bounds(clip: &Node) -> Rect { + let (bp, _) = vectorcraft_effects::clip_outline(clip).expect("the clipping path clips to something"); + bp.bounding_box() +} + +fn near(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol +} + +#[test] +fn a_text_clip_path_clips_by_the_text_as_live_type() { + let d = open( + r##"HI + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 1, "{objects:?}"); + let (clip, art) = clip_group(&objects[0]); + assert_eq!(art.len(), 1); + assert_eq!(art[0].name.as_deref(), Some("art")); + // The clipping path is the text object with no fill or stroke, as after Make Clipping Mask. + let NodeKind::Text(t) = &clip.kind else { panic!("the clipping path is {:?}", clip.kind) }; + assert_eq!(t.plain_text(), "HI"); + assert!(t.runs.iter().all(|r| r.style.fill == Paint::None && r.style.stroke == Paint::None)); + assert!(near(t.runs[0].style.size, 100.0, 1e-9), "{}", t.runs[0].style.size); + assert!(t.xf.translation().to_point().distance(Point::new(20.0, 150.0)) < 1e-9, "{:?}", t.xf); + let p = clip.clip_paint(); + assert!(p.fill.is_none() && p.stroke.is_none(), "a clip path paints nothing"); + // It clips to the glyphs: inside the line, not the whole rectangle. + let b = clip_bounds(clip); + assert!(b.x0 >= 20.0 - 1e-6 && b.x1 < 220.0 && b.y1 <= 150.0 + 1e-6 && b.y0 > 50.0 && b.width() > 40.0, "{b:?}"); +} + +#[test] +fn text_and_shapes_in_one_clip_path_clip_to_their_union() { + let d = open( + r##"O + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 2, "the rectangle and the glyph outlines: {shapes:?}"); + let rect = shapes[0].0.bounding_box(); + assert!(near(rect.x0, 300.0, 1e-6) && near(rect.y1, 50.0, 1e-6), "{rect:?}"); + let glyphs = shapes[1].0.bounding_box(); + assert!(glyphs.x0 >= 20.0 - 1e-6 && glyphs.x1 < 120.0 && glyphs.y1 < 160.0 && glyphs.y0 > 50.0, "{glyphs:?}"); + let b = clip_bounds(clip); + assert!(near(b.x1, 350.0, 1e-6) && near(b.y0, 10.0, 1e-6), "{b:?}"); +} + +#[test] +fn a_text_clip_path_keeps_its_units_and_transforms() { + // The clip path's box units and the text's own transform place the type. + let d = open( + r##" + I + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind); + let b = clip_bounds(clip); + // The box maps x 0.5…1 to 200…300 and y 0…1 to 100…200. + assert!(b.x0 >= 200.0 - 1e-6 && b.x1 <= 300.0 && b.y0 >= 100.0 && b.y1 <= 180.0 + 1e-6 && b.height() > 30.0, "{b:?}"); + // The clip path's transform moves it, as it moves a shape in it. + let d = open( + r##"HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(120.0, 170.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn a_text_clip_path_with_a_clip_rule_or_a_clip_of_its_own_clips_by_its_outlines() { + // The text's own clip path cuts its glyphs: the clip keeps their part inside the rectangle. + let d = open( + r##" + HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(!matches!(clip.kind, NodeKind::Text(_)), "outlines, as the clip of the text cuts them"); + let b = clip_bounds(clip); + assert!(b.y1 <= 120.0 + 1e-6 && b.y0 > 50.0 && b.x0 >= 20.0 - 1e-6, "{b:?}"); + // An even-odd text clip keeps its rule on the outlines. + let d = open( + r##"O + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].1, vectorcraft_geom::FillRule::EvenOdd); +} + +#[test] +fn tspans_and_type_on_a_path_clip_too() { + let d = open( + r##"AB + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.plain_text().contains('A') && t.plain_text().contains('B'), "{:?}", t.plain_text()); + let b = clip_bounds(clip); + assert!(b.y0 < 80.0 && b.y1 > 150.0, "both lines clip: {b:?}"); + + let d = open( + r##" + Around + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(matches!(t.kind, TextKind::OnPath { .. }), "{:?}", t.kind); + assert!(clip_bounds(clip).width() > 50.0); +} + +#[test] +fn a_use_of_a_text_in_a_clip_path_clips_where_the_use_places_it() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(20.0, 150.0)) < 1e-9, "{:?}", t.xf); + // Font-relative positions count in the ``'s font size; an undisplayed `` adds nothing. + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("only the displayed use: {:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(80.0, 120.0)) < 1e-9, "{:?}", t.xf); + // The same text drawn as well as used in a clip path. + let d = open( + r##"HI + + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 2, "{objects:?}"); + assert!(matches!(objects[0].kind, NodeKind::Text(_))); + let (clip, _) = clip_group(&objects[1]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 2, "{shapes:?}"); + let glyphs = shapes[0].0.bounding_box(); + assert!(glyphs.x0 >= 200.0 - 1e-6 && glyphs.y1 <= 100.0 + 1e-6 && glyphs.y0 > 40.0, "{glyphs:?}"); +} + +#[test] +fn a_clip_path_on_a_use_of_a_text_is_in_the_use_s_user_space() { + // The text's transform moves its glyphs to x 200…; the ``'s clip keeps x < 250 of its own + // user space, which the text's transform doesn't move. + let d = open( + r##" + HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let b = clip_bounds(clip); + assert!(b.x0 >= 200.0 - 1e-6 && near(b.x1, 250.0, 1e-6), "{b:?}"); + // Without a clip on the ``, the text's transform still places the type. + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(200.0, 160.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn a_css_transform_places_a_text_in_a_clip_path() { + let d = open( + r##" + HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(120.0, 170.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn css_transforms_place_a_use_of_a_text_in_a_clip_path() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(25.0, 150.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn a_use_refers_to_the_first_element_with_its_id() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(!matches!(clip.kind, NodeKind::Text(_)), "the rectangle clips: {:?}", clip.kind); + let b = clip_bounds(clip); + assert!(near(b.x0, 300.0, 1e-6) && near(b.x1, 350.0, 1e-6) && near(b.y1, 50.0, 1e-6), "{b:?}"); +} + +#[test] +fn a_text_in_a_clip_path_can_be_shown_by_a_use_elsewhere() { + let d = open( + r##"HI + + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 2, "{objects:?}"); + let (clip, _) = clip_group(&objects[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind); + let mut shown = vec![]; + objects[1].walk(&mut |n: &Node| { + if let NodeKind::Text(t) = &n.kind { + shown.push(t.plain_text()); + } + }); + assert_eq!(shown, ["HI"], "the use shows the text: {:?}", objects[1]); +} + +#[test] +fn a_use_in_a_clip_path_can_be_shown_by_another_use() { + let d = open( + r##"HI + + + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 2, "{objects:?}"); + let (clip, _) = clip_group(&objects[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind); + let mut shown = vec![]; + objects[1].walk(&mut |n: &Node| match &n.kind { + NodeKind::Text(t) => shown.push(t.plain_text()), + NodeKind::Path { .. } => shown.push("a path".into()), + _ => {} + }); + assert_eq!(shown, ["HI"], "the other use shows the text: {:?}", objects[1]); + // A rule that styles the `` where it stands still applies to what the other use shows. + let d = open( + r##" + HI + + + "##, + ); + let objects = top(&d); + let mut at = vec![]; + objects[1].walk(&mut |n: &Node| { + if let NodeKind::Text(t) = &n.kind { + at.push(t.xf.translation().to_point()); + } + }); + assert!(at.len() == 1 && at[0].distance(Point::new(60.0, 200.0)) < 1e-9, "{at:?}"); +} + +#[test] +fn style_rules_for_paths_do_not_reach_a_text_in_a_clip_path() { + let d = open( + r##" + HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "the visible, non-zero text: {:?}", clip.kind); + // The text's own values, and those it inherits, still apply. + let d = open( + r##" + O + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].1, vectorcraft_geom::FillRule::EvenOdd); +} + +#[test] +fn a_hidden_text_alone_in_a_clip_path_is_not_live_type() { + let d = open( + r##"HI + "##, + ); + let objects = top(&d); + let clips = objects.iter().filter_map(|n| match &n.kind { + NodeKind::Group { children, clip: true } => children.first(), + _ => None, + }); + for clip in clips { + assert!(!matches!(clip.kind, NodeKind::Text(_)), "a hidden text clips nothing: {:?}", clip.kind); + } +} + +#[test] +fn a_text_left_out_by_its_conditions_clips_nothing() { + let d = open( + r##"HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let b = clip_bounds(clip); + assert!(near(b.x1, 10.0, 1e-6) && near(b.y1, 10.0, 1e-6), "only the rectangle: {b:?}"); +} + +#[test] +fn a_text_clip_on_a_symbol_instance_cuts_art_between_the_letters() { + // The art sits in the space between the two letters, inside the text's box: the glyphs cut it, + // so it stays clipped art instead of becoming an instance without the clip. + let body = |clip: &str| { + format!( + r##" + {clip} + "## + ) + }; + let instances = |d: &Document| { + let mut n = 0; + d.walk(|node| n += usize::from(matches!(node.kind, NodeKind::SymbolInstance { .. }))); + n + }; + let d = open(&body(r#"I I"#)); + assert_eq!(instances(&d), 0, "{:?}", top(&d)[0].kind); + assert!(matches!(top(&d)[0].kind, NodeKind::Group { clip: true, .. })); + // A clip around the art leaves it whole: an instance. + let d = open(&body(r#""#)); + assert_eq!(instances(&d), 1); +} + +#[test] +fn a_clip_path_clipped_by_a_text_clip_path_nests() { + let d = open( + r##"HI + + "##, + ); + let (clip, art) = clip_group(&top(&d)[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "the outermost clip is the text: {:?}", clip.kind); + let (inner, _) = clip_group(&art[0]); + assert!(matches!(inner.kind, NodeKind::Path { clipping: true, .. }), "{:?}", inner.kind); +} + +#[test] +fn hidden_text_in_a_clip_path_clips_nothing() { + let d = open( + r##"HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let b = clip_bounds(clip); + assert!(near(b.x1, 10.0, 1e-6) && near(b.y1, 10.0, 1e-6), "only the rectangle: {b:?}"); +} + +#[test] +fn text_further_down_in_a_clip_path_is_still_not_clip_path_content() { + let body = r##"No + "##; + let (_, warnings) = import_with_report(&svg(body)).unwrap(); + assert!(warnings.iter().any(|w| w.contains("in a clip path ignored")), "{warnings:?}"); +} + +#[test] +fn a_text_clip_survives_a_round_trip_through_svg_export() { + let d = open( + r##"HI + "##, + ); + let before = clip_bounds(clip_group(&top(&d)[0]).0); + let again = import(&export(&d, &ExportOptions::default())).unwrap(); + let mut clips = vec![]; + again.walk(|n| { + if let NodeKind::Group { children, clip: true } = &n.kind { + clips.push(children[0].clone()); + } + }); + assert_eq!(clips.len(), 1); + let after = clip_bounds(&clips[0]); + let tol = 0.01 * before.width().max(before.height()); + assert!( + near(after.x0, before.x0, tol) && near(after.x1, before.x1, tol) && near(after.y0, before.y0, tol) && near(after.y1, before.y1, tol), + "{before:?} → {after:?}" + ); +} diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 7d0e08c0b..a30bc193b 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -108,6 +108,19 @@ fn text_keeps_its_z_order_between_shapes() { assert!(t.xf.translation().to_point().distance(Point::new(5.0, 20.0)) < 1e-9); } +#[test] +fn a_css_transform_places_text() { + // A `transform` declaration in a rule or `style` beats the attribute, as on shapes. + for body in [ + r#"Moved"#, + r#"Moved"#, + ] { + let d = open(body); + let t = only_text(&d); + assert!(t.xf.translation().to_point().distance(Point::new(10.0, 40.0)) < 1e-9, "{body}: {:?}", t.xf); + } +} + #[test] fn text_keeps_its_parent_group_clip_and_mask() { let d = open( diff --git a/crates/svg/tests/it/main.rs b/crates/svg/tests/it/main.rs index 3605bd014..09c175a37 100644 --- a/crates/svg/tests/it/main.rs +++ b/crates/svg/tests/it/main.rs @@ -4,6 +4,7 @@ mod basic_shapes; mod blend_export; +mod clip_text; mod colorized_images; mod colour_glyphs; mod container_export; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8237bc877..974ef2405 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -342,7 +342,7 @@ impl VectorcraftApp { self.poll_finished_work(); crate::script_runs::poll(self); crate::scripts_menu::poll(self, ctx); - if self.session.script_running() && !self.session.script_waiting() { + if self.session.script_running() && !self.session.script_waiting() && self.session.script_dialog().is_none() { ctx.request_repaint(); } #[cfg(not(target_arch = "wasm32"))] @@ -371,9 +371,7 @@ impl VectorcraftApp { unsaved::finish_pending(self); if self.session.script_running() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - if self.ui.dialog.is_none() { - self.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::CLOSE_KIND, serde_json::json!({}))); - } + dialogs::script_run::ask_to_close(self); } else if self.file_sheet_open() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.status(picks::busy()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index edea838ec..c13a230a9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -410,6 +410,7 @@ registry! { ScriptRun: [script_run::KIND] => script_run::SPEC, ScriptGrowth: [script_run::GROWTH_KIND] => script_run::GROWTH_SPEC, ScriptClose: [script_run::CLOSE_KIND] => script_run::CLOSE_SPEC, + ScriptNotice: [script_run::NOTICE_KIND] => script_run::NOTICE_SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, SmoothSlider: [smooth_slider::KIND] => smooth_slider::SPEC, @@ -588,6 +589,9 @@ pub fn cancel(app: &mut VectorcraftApp) { if d.kind == script_run::GROWTH_KIND { app.session.script_request_stop(); } + if d.kind == script_run::NOTICE_KIND { + script_run::notice_cancel(app, &d); + } // Toolbar-only Flare options start no preview and must not cancel a gesture // that was already active before opening them. The generic parameter dialog has a spec // without a preview, yet previews some commands (Grid Repeat Options) when asked to. diff --git a/crates/ui-egui/src/dialogs/script_run.rs b/crates/ui-egui/src/dialogs/script_run.rs index 2ef43cb8f..8f5e3b109 100644 --- a/crates/ui-egui/src/dialogs/script_run.rs +++ b/crates/ui-egui/src/dialogs/script_run.rs @@ -1,16 +1,25 @@ //! File › Scripts › Other Script…: choosing a JavaScript file and the warning before it runs -//! (`script.run` runs it), the question a run that keeps adding art asks, and the question quitting -//! asks while a script runs. +//! (`script.run` runs it), the question a run that keeps adding art asks, the question quitting +//! asks while a script runs, and the dialogs a script opens with `alert`, `confirm` and `prompt`. //! //! `ui.runScript {path? | source?, name?}` shows the warning for that script; without either it //! asks for a `.js` or `.jsx` file (the web opens the browser's file picker). With Preferences › //! General › Show External Script Warning off, the chosen script runs at once. Warning fields: //! `name`, `script` (`script.run`'s params) and `dontShow` (true: turn the warning off). +//! +//! A script's dialog (kind `scriptDialog`) has the fields `kind` (alert, confirm or prompt), +//! `message`, `title` (null: Script Alert), `errorIcon`, `noAsDefault` and `value` (the prompt's +//! text), and takes `answer`: `ok` (alert, prompt), `yes` or `no` (confirm), `cancel` (prompt) or +//! `stop`. OK, Enter or `ui.dialog.confirm` without an `answer` gives the default button's answer. +//! Escape or `ui.dialog.cancel` gives Cancel for a prompt, No for a confirm and Stop for an alert. +//! Stop ends the run and puts back everything it did. use serde_json::{Value, json}; use vectorcraft_engine::cmd::scripts::{RUN_EXTS, TOO_LARGE, runs}; +use vectorcraft_engine::script::dialogs::{MAX_PROMPT_BYTES, cut_to, shown_text}; +use vectorcraft_engine::script::{DialogAnswer, ScriptDialog}; -use super::DialogSpec; +use super::{DialogAction, DialogSpec}; use crate::state::Dialog; use crate::theme::{self, Tokens}; use crate::{FilePick, VectorcraftApp, widgets}; @@ -21,6 +30,19 @@ pub const KIND: &str = "scriptRun"; pub const GROWTH_KIND: &str = "scriptGrowth"; /// The dialog kind of the question quitting asks while a script runs. pub const CLOSE_KIND: &str = "scriptClose"; +/// The dialog kind of a script's `alert`, `confirm` and `prompt`. +pub const NOTICE_KIND: &str = "scriptDialog"; + +pub(super) const NOTICE_SPEC: DialogSpec = DialogSpec { + heading: notice_heading, + body_with_actions: Some(notice_body), + confirm: notice_confirm, + ok: None, + inputs: |_| &["answer"], + min_width: 360.0, + max_width: Some(560.0), + ..DialogSpec::FORM +}; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Run Script").into(), @@ -145,3 +167,170 @@ fn close_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result } Ok(Value::Null) } + +/// Quitting while a script runs asks whether to stop it, also over the script's own dialog, which +/// Cancel shows again as it was. Any other open dialog stays, and quitting asks nothing yet. +pub(crate) fn ask_to_close(app: &mut VectorcraftApp) { + let notice = app.ui.dialog.take_if(|d| d.kind == NOTICE_KIND); + if app.ui.dialog.is_some() { + return; + } + let mut fields = json!({}); + if let Some(notice) = notice { + fields[super::PARENT] = serde_json::to_value(notice).unwrap_or_default(); + } + app.ui.dialog = Some(Dialog::new(CLOSE_KIND, fields)); +} + +/// Closes the question quitting asks once the run it asks about has ended, and forgets the +/// script's dialog it was asked over once the run no longer waits at that dialog, so Cancel does +/// not show it again. +pub(crate) fn settle_close(app: &mut VectorcraftApp) { + if !app.ui.dialog.as_ref().is_some_and(|d| d.kind == CLOSE_KIND) { + return; + } + if !app.session.script_running() { + app.ui.dialog = None; + } else if app.session.script_dialog().is_none() + && let Some(d) = app.ui.dialog.as_mut() + { + d.fields.remove(super::PARENT); + } +} + +/// Shows the dialog the running script waits at. +pub(crate) fn open_notice(app: &mut VectorcraftApp, d: &ScriptDialog) { + let fields = json!({ + "kind": d.kind.id(), + "message": d.message, + "title": d.title, + "errorIcon": d.error_icon, + "noAsDefault": d.no_as_default, + "value": d.preset, + }); + app.ui.dialog = Some(Dialog::new(NOTICE_KIND, fields)); +} + +/// The dialog's heading: the script's title on one line, or Script Alert. +pub(crate) fn notice_heading(d: &Dialog) -> String { + match d.fields.get("title").and_then(Value::as_str) { + Some(title) => shown_text(title, false), + None => tl!("Script Alert").into(), + } +} + +/// The answer of the default button: OK, or for a confirm Yes, or No with `noAsDefault`. +fn default_answer(d: &Dialog) -> &'static str { + match (d.str("kind").as_str(), d.bool("noAsDefault")) { + ("confirm", true) => "no", + ("confirm", false) => "yes", + _ => "ok", + } +} + +/// A button that is the primary one when it is the default. +fn answer_button(ui: &mut egui::Ui, label: &str, default: bool) -> bool { + if default { widgets::primary_button_enabled(ui, label, true).clicked() } else { widgets::secondary_button(ui, label).clicked() } +} + +fn notice_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> DialogAction { + let t = Tokens::get(ui.ctx()); + let kind = d.str("kind"); + let message = shown_text(&d.str("message"), true); + ui.horizontal_top(|ui| { + if d.bool("errorIcon") { + ui.label(egui::RichText::new("!").font(theme::semibold(22.0)).color(t.error)); + ui.add_space(8.0); + } + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| ui.label(&message)); + }); + if kind == "prompt" { + ui.add_space(8.0); + let name = cut_to(&shown_text(&message, false), 200).to_string(); + super::form::text_edit_named(ui, d, "value", &name, ui.available_width()); + let value = d.str("value"); + if value.len() > MAX_PROMPT_BYTES { + d.fields.insert("value".into(), json!(cut_to(&value, MAX_PROMPT_BYTES))); + } + } + ui.add_space(16.0); + let default = default_answer(d); + let mut answer = None; + let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); + ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { + let buttons: &[(&str, &str)] = match kind.as_str() { + "confirm" => &[("yes", "Yes"), ("no", "No")], + "prompt" => &[("ok", "OK"), ("cancel", "Cancel")], + _ => &[("ok", "OK")], + }; + for (id, label) in buttons { + if answer_button(ui, tl!(label), *id == default) { + answer = Some(*id); + } + ui.add_space(8.0); + } + // The row runs right to left: Stop goes at its left end. + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + if widgets::secondary_button(ui, tl!("Stop")).clicked() { + answer = Some("stop"); + } + }); + }); + if answer.is_none() && crate::shortcuts::pressed_anew(ui.ctx(), egui::Key::Enter) { + answer = Some(default); + } + let Some(answer) = answer else { return DialogAction::None }; + d.fields.insert("answer".into(), json!(answer)); + DialogAction::Confirm +} + +/// Gives the running script the dialog's `answer`, or the default button's. +fn notice_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let answer = d.fields.get("answer").and_then(Value::as_str).unwrap_or(default_answer(d)); + give(app, d, answer) +} + +/// Escape or Cancel: Cancel for a prompt, No for a confirm and Stop for an alert. +pub(super) fn notice_cancel(app: &mut VectorcraftApp, d: &Dialog) { + let answer = match d.str("kind").as_str() { + "prompt" => "cancel", + "confirm" => "no", + _ => "stop", + }; + let _ = give(app, d, answer); +} + +fn give(app: &mut VectorcraftApp, d: &Dialog, answer: &str) -> Result { + let given = match (d.str("kind").as_str(), answer) { + (_, "stop") => None, + ("alert", "ok") => Some(DialogAnswer::Ok), + ("confirm", "yes") => Some(DialogAnswer::Confirm(true)), + ("confirm", "no") => Some(DialogAnswer::Confirm(false)), + ("prompt", "ok") => Some(DialogAnswer::Prompt(Some(d.str("value")))), + ("prompt", "cancel") => Some(DialogAnswer::Prompt(None)), + (kind, _) => return Err(format!("`answer` for this {kind} dialog must be one of: {}", answers_for(kind))), + }; + match given { + None => { + app.session.script_request_stop(); + } + Some(a) => { + // Refused while the dialog still waits: the text is too long, and the dialog stays open. + if !app.session.script_answer(a) && app.session.script_dialog().is_some() { + return Err(format!("the prompt's text is longer than {MAX_PROMPT_BYTES} bytes")); + } + } + } + if app.ui.dialog.as_ref().is_some_and(|open| open.kind == NOTICE_KIND) { + app.ui.dialog = None; + } + Ok(Value::Null) +} + +fn answers_for(kind: &str) -> &'static str { + match kind { + "confirm" => "yes, no, stop", + "prompt" => "ok, cancel, stop", + _ => "ok, stop", + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2d4949879..fad199b6f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3071,6 +3071,13 @@ digits Malayalam Малаялам Quest Outline Квест Coin Shine Монета Boss Alert Бос + Status Effects Статус ефекти + Poison Tint Отрова + Freeze Edge Замразяване + Burn Aura Изгаряне + Stun Pulse Зашеметяване + Stealth Fade Скритост + Shield Buff Щит Shape Фигура Shape Builder Създател на фигури Shape Builder Regions Области на създателя на фигури @@ -3980,6 +3987,7 @@ axis Both И двете 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. Да се спре ли скриптът и да се отменят ли промените му? След това излезте отново. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 912eec6a6..8ac2da69d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -776,6 +776,13 @@ Quest Outline Zadatak Coin Shine Novčić Boss Alert Bos + Status Effects Statusni efekti + Poison Tint Otrov + Freeze Edge Zamrzavanje + Burn Aura Izgaranje + Stun Pulse Omamljenost + Stealth Fade Nevidljivost + Shield Buff Štit Outlines and Rules Obrisi i linije Hand-Drawn Ručno crtano Gradient Finishes Gradijentne završnice @@ -4345,6 +4352,7 @@ imageTrace Outline Obris Stop Script Zaustavi skriptu Show External Script Warning Prikaži upozorenje za vanjske skripte A Script Is Running Skripta se izvršava + Script Alert Upozorenje skripte A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skripta može mijenjati otvorene dokumente i pisati datoteke putem naredbi koje pokreće. Pokrećite samo skripte kojima vjerujete. Do not show this warning again Ne prikazuj više ovo upozorenje Stop the script and undo what it did? Then quit again. Zaustaviti skriptu i poništiti ono što je uradila? Zatim ponovo izađite. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f12bb4a6a..7747fae56 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3955,6 +3955,13 @@ digits Malayalam Malaiàlam Quest Outline Missió Coin Shine Moneda Boss Alert Cap + Status Effects Efectes d’estat + Poison Tint Verí + Freeze Edge Congelació + Burn Aura Cremada + Stun Pulse Atordiment + Stealth Fade Sigil + Shield Buff Escut Shape Forma Shape Builder Constructor de formes Shape Builder Delete Suprimeix del constructor de formes @@ -5196,6 +5203,7 @@ digits Malayalam Malaiàlam Stop Script Atura l'script Show External Script Warning Mostra l'avís dels scripts externs A Script Is Running S'està executant un script + Script Alert Avís de l'script A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script pot canviar els documents oberts i escriure fitxers mitjançant les ordres que executa. Executeu només scripts de confiança. Do not show this warning again No tornis a mostrar aquest avís Stop the script and undo what it did? Then quit again. Voleu aturar l'script i desfer el que ha fet? Després, torneu a sortir. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6d7965acd..459fb926e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -783,6 +783,13 @@ Quest Outline Úkol Coin Shine Mince Boss Alert Boss + Status Effects Stavové efekty + Poison Tint Jed + Freeze Edge Zmrazení + Burn Aura Spálení + Stun Pulse Omráčení + Stealth Fade Plížení + Shield Buff Štít Outlines and Rules Obrysy a linky Hand-Drawn Ručně kreslené Gradient Finishes Přechodové povrchy @@ -4401,6 +4408,7 @@ imageTrace Outline Obrys Stop Script Zastavit skript Show External Script Warning Zobrazit upozornění u externích skriptů A Script Is Running Běží skript + Script Alert Upozornění skriptu A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skript může měnit otevřené dokumenty a prostřednictvím příkazů, které spouští, zapisovat soubory. Spouštějte jen skripty, kterým důvěřujete. Do not show this warning again Toto upozornění již nezobrazovat Stop the script and undo what it did? Then quit again. Zastavit skript a vrátit jeho změny? Potom aplikaci ukončete znovu. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 53036e9dd..135e974f5 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -722,6 +722,13 @@ Quest Outline Quest Coin Shine Mønt Boss Alert Boss + Status Effects Statuseffekter + Poison Tint Gift + Freeze Edge Frost + Burn Aura Brænd + Stun Pulse Bedøvelse + Stealth Fade Snige + Shield Buff Skjold Outlines and Rules Konturer og linjer Hand-Drawn Håndtegnet Gradient Finishes Gradientfinish @@ -4099,6 +4106,7 @@ imageTrace Outline Kontur Stop Script Stop script Show External Script Warning Vis advarsel om eksterne scripts A Script Is Running Et script kører + Script Alert Scriptadvarsel A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Et script kan ændre de åbne dokumenter og skrive filer gennem de kommandoer, det kører. Kør kun scripts, du stoler på. Do not show this warning again Vis ikke denne advarsel igen Stop the script and undo what it did? Then quit again. Stop scriptet og fortryd det, det har gjort? Afslut derefter igen. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e46a1b6ea..b9987a923 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3117,6 +3117,13 @@ digits Malayalam Malayalam Quest Outline Quest Coin Shine Münze Boss Alert Boss + Status Effects Statuseffekte + Poison Tint Gift + Freeze Edge Frost + Burn Aura Brand + Stun Pulse Betäubung + Stealth Fade Schleichen + Shield Buff Schild Shape Form Shape Builder Formerstellung Shape Builder Regions Formerstellungsbereiche @@ -4007,6 +4014,7 @@ theme Appearance Erscheinungsbild Stop Script Skript stoppen Show External Script Warning Warnung bei externen Skripten anzeigen A Script Is Running Ein Skript wird ausgeführt + Script Alert Skriptmeldung A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ein Skript kann die geöffneten Dokumente ändern und über die ausgeführten Befehle Dateien schreiben. Führen Sie nur Skripte aus, denen Sie vertrauen. Do not show this warning again Diese Warnung nicht mehr anzeigen Stop the script and undo what it did? Then quit again. Skript stoppen und seine Änderungen rückgängig machen? Beenden Sie danach erneut. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 98015264e..1b4ffb01f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2247,6 +2247,7 @@ axis Both Και τα δύο 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. Διακοπή του σεναρίου και αναίρεση όσων έκανε; Έπειτα κάντε ξανά έξοδο. @@ -3624,6 +3625,13 @@ digits Malayalam Μαλαγιάλαμ Quest Outline Αποστολή Coin Shine Νόμισμα Boss Alert Αρχηγός + Status Effects Εφέ κατάστασης + Poison Tint Δηλητήριο + Freeze Edge Πάγωμα + Burn Aura Κάψιμο + Stun Pulse Ζάλη + Stealth Fade Αορασία + Shield Buff Ασπίδα Shape Builder Regions Περιοχές δημιουργίας σχημάτων Shape Effects Εφέ σχημάτων Shape Mode Λειτουργία σχήματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d5d844b23..71a6dbb20 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3156,6 +3156,13 @@ digits Malayalam Malayálam Quest Outline Misión Coin Shine Moneda Boss Alert Jefe + Status Effects Efectos de estado + Poison Tint Veneno + Freeze Edge Congelación + Burn Aura Quemadura + Stun Pulse Aturdimiento + Stealth Fade Sigilo + Shield Buff Escudo Shape Forma Shape Builder Generador de formas Shape Builder Regions Regiones del generador de formas @@ -4091,6 +4098,7 @@ axis Both Ambos Stop Script Detener script Show External Script Warning Mostrar advertencia de scripts externos A Script Is Running Se está ejecutando un script + Script Alert Aviso del script A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script puede cambiar los documentos abiertos y escribir archivos mediante los comandos que ejecuta. Ejecute solo scripts de confianza. Do not show this warning again No volver a mostrar esta advertencia Stop the script and undo what it did? Then quit again. ¿Detener el script y deshacer lo que hizo? Después, vuelva a salir. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 85b49e77e..ea0e49d5b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -774,6 +774,13 @@ Quest Outline Tehtävä Coin Shine Kolikko Boss Alert Bossi + Status Effects Tilatehosteet + Poison Tint Myrkky + Freeze Edge Jäädytys + Burn Aura Poltto + Stun Pulse Tainnutus + Stealth Fade Hiipiminen + Shield Buff Kilpi Outlines and Rules Ääriviivat ja viivat Hand-Drawn Käsin piirretyt Gradient Finishes Liukuväriviimeistelyt @@ -4315,6 +4322,7 @@ imageTrace Outline Ääriviiva Stop Script Pysäytä komentosarja Show External Script Warning Näytä ulkoisten komentosarjojen varoitus A Script Is Running Komentosarja on käynnissä + Script Alert Komentosarjan ilmoitus A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Komentosarja voi muuttaa avoimia asiakirjoja ja kirjoittaa tiedostoja suorittamillaan komennoilla. Suorita vain komentosarjoja, joihin luotat. Do not show this warning again Älä näytä tätä varoitusta uudelleen Stop the script and undo what it did? Then quit again. Pysäytetäänkö komentosarja ja kumotaan sen muutokset? Lopeta sitten uudelleen. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 43eb6d757..c8a0551b8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3148,6 +3148,13 @@ digits Malayalam Malayalam Quest Outline Quête Coin Shine Pièce Boss Alert Boss + Status Effects Effets de statut + Poison Tint Poison + Freeze Edge Gel + Burn Aura Brûlure + Stun Pulse Étourdissement + Stealth Fade Furtivité + Shield Buff Bouclier Shape Forme Shape Builder Concepteur de forme Shape Builder Regions Zones du Concepteur de forme @@ -4080,6 +4087,7 @@ axis Both Les deux Stop Script Arrêter le script Show External Script Warning Afficher l’avertissement des scripts externes A Script Is Running Un script est en cours d’exécution + Script Alert Alerte de script A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script peut modifier les documents ouverts et écrire des fichiers au moyen des commandes qu’il exécute. N’exécutez que des scripts de confiance. Do not show this warning again Ne plus afficher cet avertissement Stop the script and undo what it did? Then quit again. Arrêter le script et annuler ce qu’il a fait ? Quittez ensuite à nouveau. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 796939065..7a2d26da7 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4267,6 +4267,13 @@ antiAlias Sharp शार्प Quest Outline क्वेस्ट Coin Shine सिक्का Boss Alert बॉस + Status Effects स्थिति प्रभाव + Poison Tint ज़हर + Freeze Edge ठंड + Burn Aura जलन + Stun Pulse स्तब्ध + Stealth Fade छिपना + Shield Buff ढाल Shaft: दस्ता: Shape शेप Shape Builder शेप बिल्डर @@ -6186,6 +6193,7 @@ antiAlias Sharp शार्प 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. स्क्रिप्ट रोककर उसके बदलाव पूर्ववत करें? फिर दोबारा बाहर निकलें। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ec7d13a8c..ab0ea4314 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3771,6 +3771,13 @@ mark-style Japanese Japán Quest Outline Küldetés Coin Shine Érme Boss Alert Főellenség + Status Effects Állapot hatások + Poison Tint Méreg + Freeze Edge Fagy + Burn Aura Égés + Stun Pulse Kábítás + Stealth Fade Lopakodás + Shield Buff Pajzs Shape Alakzat Shape Builder Alakzatépítő Shape Builder Regions Alakzatépítő területei @@ -4888,6 +4895,7 @@ imageTrace Outline Körvonal Stop Script Parancsfájl leállítása Show External Script Warning Figyelmeztetés külső parancsfájlok előtt A Script Is Running Egy parancsfájl fut + Script Alert Parancsfájl-értesítés A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. A parancsfájl módosíthatja a megnyitott dokumentumokat, és a futtatott parancsokkal fájlokat írhat. Csak megbízható parancsfájlokat futtasson. Do not show this warning again Ne jelenjen meg többé ez a figyelmeztetés Stop the script and undo what it did? Then quit again. Leállítja a parancsfájlt, és visszavonja a módosításait? Ezután lépjen ki újra. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 45d30eb0f..9a5735be4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -722,6 +722,13 @@ Quest Outline Quest Coin Shine Koin Boss Alert Boss + Status Effects Efek Status + Poison Tint Racun + Freeze Edge Beku + Burn Aura Bakar + Stun Pulse Pingsan + Stealth Fade Menghilang + Shield Buff Perisai Outlines and Rules Garis luar dan garis Hand-Drawn Gambar tangan Gradient Finishes Finishing gradien @@ -4099,6 +4106,7 @@ imageTrace Outline Kontur Stop Script Hentikan Skrip Show External Script Warning Tampilkan Peringatan Skrip Eksternal A Script Is Running Skrip Sedang Berjalan + Script Alert Peringatan Skrip A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skrip dapat mengubah dokumen yang terbuka dan menulis file melalui perintah yang dijalankannya. Jalankan hanya skrip yang Anda percayai. Do not show this warning again Jangan tampilkan peringatan ini lagi Stop the script and undo what it did? Then quit again. Hentikan skrip dan batalkan yang telah dilakukannya? Lalu keluar lagi. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 419cf0582..a6d504912 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3193,6 +3193,13 @@ digits Malayalam Malayalam Quest Outline Missione Coin Shine Moneta Boss Alert Boss + Status Effects Effetti di stato + Poison Tint Veleno + Freeze Edge Gelo + Burn Aura Bruciatura + Stun Pulse Stordimento + Stealth Fade Furtività + Shield Buff Scudo Shape Forma Shape Builder Generatore forme Shape Builder Regions Aree del Generatore forme @@ -4134,6 +4141,7 @@ axis Both Entrambi Stop Script Interrompi script Show External Script Warning Mostra avviso per gli script esterni A Script Is Running È in esecuzione uno script + Script Alert Avviso dello script A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Uno script può modificare i documenti aperti e scrivere file tramite i comandi che esegue. Esegui solo script di cui ti fidi. Do not show this warning again Non mostrare più questo avviso Stop the script and undo what it did? Then quit again. Interrompere lo script e annullare ciò che ha fatto? Poi esci di nuovo. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index effd2a900..7f59137f2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -807,6 +807,13 @@ digits Malayalam マラヤーラム数字 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 グラデーション仕上げ @@ -4864,6 +4871,7 @@ swatch Bright Violet ビビッドバイオレット 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. スクリプトを停止して変更を取り消しますか?その後、もう一度終了してください。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index e1b2fceef..82f646ac8 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -670,6 +670,13 @@ 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 გრადიენტული ზედაპირები @@ -5179,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. შეჩერდეს სკრიპტი და გაუქმდეს მისი ცვლილებები? შემდეგ ხელახლა გამოდით. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2fa677459..4863fcee8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -800,6 +800,13 @@ digits Malayalam 말라얄람어 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 그레이디언트 마감 @@ -4791,6 +4798,7 @@ theme Appearance 외관 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. 스크립트를 중지하고 변경 내용을 취소할까요? 그런 다음 다시 종료하세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 10c923cd6..1800d282b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -736,6 +736,13 @@ Quest Outline Quest Coin Shine Munt Boss Alert Baas + Status Effects Statuseffecten + Poison Tint Gif + Freeze Edge Bevriezing + Burn Aura Brand + Stun Pulse Verdoving + Stealth Fade Sluipen + Shield Buff Schild Outlines and Rules Omtrekken en lijnen Hand-Drawn Handgetekend Gradient Finishes Verloopafwerkingen @@ -4113,6 +4120,7 @@ imageTrace Outline Contour Stop Script Script stoppen Show External Script Warning Waarschuwing voor externe scripts tonen A Script Is Running Er wordt een script uitgevoerd + Script Alert Scriptmelding A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Een script kan de geopende documenten wijzigen en via de uitgevoerde opdrachten bestanden schrijven. Voer alleen scripts uit die u vertrouwt. Do not show this warning again Deze waarschuwing niet meer tonen Stop the script and undo what it did? Then quit again. Het script stoppen en de wijzigingen ervan ongedaan maken? Sluit daarna opnieuw af. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a4f8580ff..0cde37cf3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -730,6 +730,13 @@ Quest Outline Zadanie Coin Shine Moneta Boss Alert Boss + Status Effects Efekty statusu + Poison Tint Trucizna + Freeze Edge Mróz + Burn Aura Oparzenie + Stun Pulse Ogłuszenie + Stealth Fade Ukrycie + Shield Buff Tarcza Outlines and Rules Kontury i linie Hand-Drawn Rysowane odręcznie Gradient Finishes Wykończenia gradientowe @@ -4224,6 +4231,7 @@ imageTrace Outline Kontur Stop Script Zatrzymaj skrypt Show External Script Warning Pokazuj ostrzeżenie o skryptach zewnętrznych A Script Is Running Skrypt jest uruchomiony + Script Alert Komunikat skryptu A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skrypt może zmieniać otwarte dokumenty i zapisywać pliki za pomocą wykonywanych poleceń. Uruchamiaj tylko skrypty, którym ufasz. Do not show this warning again Nie pokazuj więcej tego ostrzeżenia Stop the script and undo what it did? Then quit again. Zatrzymać skrypt i cofnąć jego zmiany? Następnie zakończ program ponownie. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2496b775e..b298a6b4a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2648,6 +2648,13 @@ digits Malayalam Malaiala Quest Outline Missão Coin Shine Moeda Boss Alert Chefe + Status Effects Efeitos de status + Poison Tint Veneno + Freeze Edge Congelamento + Burn Aura Queimadura + Stun Pulse Atordoamento + Stealth Fade Furtividade + Shield Buff Escudo Shape Forma Shape Builder Construtor de Formas Shape Builder Regions Regiões do Construtor de Formas @@ -5068,6 +5075,7 @@ axis Both Ambos Stop Script Parar script Show External Script Warning Mostrar aviso de scripts externos A Script Is Running Um script está em execução + Script Alert Alerta do script A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Um script pode alterar os documentos abertos e gravar arquivos pelos comandos que executa. Execute apenas scripts em que você confia. Do not show this warning again Não mostrar este aviso novamente Stop the script and undo what it did? Then quit again. Parar o script e desfazer o que ele fez? Depois, saia novamente. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5f34c30ec..35a446596 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3188,6 +3188,13 @@ digits Malayalam Малаялам Quest Outline Квест Coin Shine Монета Boss Alert Босс + Status Effects Эффекты статуса + Poison Tint Яд + Freeze Edge Заморозка + Burn Aura Ожог + Stun Pulse Оглушение + Stealth Fade Скрытность + Shield Buff Щит Shape Фигура Shape Builder Создание фигур Shape Builder Regions Области создания фигур @@ -4128,6 +4135,7 @@ axis Both Оба 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. Остановить скрипт и отменить его изменения? Затем выйдите ещё раз. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index aab245107..4d8baea24 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3080,6 +3080,13 @@ digits Malayalam Malayalam Quest Outline Uppdrag Coin Shine Mynt Boss Alert Boss + Status Effects Statuseffekter + Poison Tint Gift + Freeze Edge Frost + Burn Aura Bränna + Stun Pulse Bedövning + Stealth Fade Smyga + Shield Buff Sköld Shape Form Shape Builder Formbyggare Shape Builder Regions Formbyggarområden @@ -4001,6 +4008,7 @@ axis Both Båda Stop Script Stoppa skript Show External Script Warning Visa varning för externa skript A Script Is Running Ett skript körs + Script Alert Skriptmeddelande A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ett skript kan ändra de öppna dokumenten och skriva filer genom de kommandon det kör. Kör bara skript du litar på. Do not show this warning again Visa inte den här varningen igen Stop the script and undo what it did? Then quit again. Stoppa skriptet och ångra det det gjort? Avsluta sedan igen. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 12e5a14e3..1eee85240 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2984,6 +2984,13 @@ digits Malayalam Malayalam Quest Outline Görev Coin Shine Jeton Boss Alert Boss + Status Effects Durum Etkileri + Poison Tint Zehir + Freeze Edge Donma + Burn Aura Yanma + Stun Pulse Sersemletme + Stealth Fade Gizlilik + Shield Buff Kalkan Shape Şekil Shape Builder Şekil Oluşturucu Shape Builder Regions Şekil Oluşturucu Bölgeleri @@ -3883,6 +3890,7 @@ axis Both Her İkisi Stop Script Betiği Durdur Show External Script Warning Harici Betik Uyarısını Göster A Script Is Running Bir Betik Çalışıyor + Script Alert Betik Uyarısı A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Bir betik açık belgeleri değiştirebilir ve çalıştırdığı komutlarla dosya yazabilir. Yalnızca güvendiğiniz betikleri çalıştırın. Do not show this warning again Bu uyarıyı bir daha gösterme Stop the script and undo what it did? Then quit again. Betik durdurulsun ve yaptıkları geri alınsın mı? Ardından yeniden çıkın. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a7854dc66..02579e1ad 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3185,6 +3185,13 @@ digits Malayalam Малаялам Quest Outline Квест Coin Shine Монета Boss Alert Бос + Status Effects Ефекти статусу + Poison Tint Отрута + Freeze Edge Заморозка + Burn Aura Опік + Stun Pulse Оглушення + Stealth Fade Невидимість + Shield Buff Щит Shape Фігура Shape Builder Створення фігур Shape Builder Regions Області створення фігур @@ -4118,6 +4125,7 @@ axis Both Обидві 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. Зупинити скрипт і скасувати його зміни? Потім вийдіть ще раз. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ce683ff03..68a40aaea 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3517,6 +3517,13 @@ mark-style Japanese Kiểu Nhật Quest Outline Nhiệm vụ Coin Shine Xu Boss Alert Trùm + Status Effects Hiệu ứng trạng thái + Poison Tint Độc + Freeze Edge Đóng băng + Burn Aura Bỏng + Stun Pulse Choáng + Stealth Fade Ẩn thân + Shield Buff Khiên Shape Hình dạng Shape Builder Tạo hình dạng Shape Builder Regions Vùng Tạo hình dạng @@ -4537,6 +4544,7 @@ theme Appearance Giao diện Stop Script Dừng tập lệnh Show External Script Warning Hiện cảnh báo tập lệnh bên ngoài A Script Is Running Một tập lệnh đang chạy + Script Alert Thông báo của tập lệnh A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Tập lệnh có thể thay đổi các tài liệu đang mở và ghi tệp thông qua các lệnh mà nó chạy. Chỉ chạy những tập lệnh bạn tin cậy. Do not show this warning again Không hiện lại cảnh báo này Stop the script and undo what it did? Then quit again. Dừng tập lệnh và hoàn tác những gì nó đã làm? Sau đó hãy thoát lại. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 91d0e4e74..fce54920d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3023,6 +3023,13 @@ digits Malayalam 马拉雅拉姆数字 Quest Outline 任务 Coin Shine 金币 Boss Alert 首领 + Status Effects 状态效果 + Poison Tint 中毒 + Freeze Edge 冰冻 + Burn Aura 灼烧 + Stun Pulse 眩晕 + Stealth Fade 隐身 + Shield Buff 护盾 Shape 形状 Shape Builder 形状生成器 Shape Builder Regions 形状生成器区域 @@ -4571,6 +4578,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. 要停止脚本并撤销其更改吗?然后请再次退出。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 399377101..9d077842f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2397,6 +2397,7 @@ file-filter Templates 範本 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. 要停止指令碼並還原其變更嗎?然後請再次結束。 @@ -4134,6 +4135,13 @@ digits Malayalam 馬來亞拉姆數字 Quest Outline 任務 Coin Shine 金幣 Boss Alert 首領 + Status Effects 狀態效果 + Poison Tint 中毒 + Freeze Edge 冰凍 + Burn Aura 灼燒 + Stun Pulse 暈眩 + Stealth Fade 隱身 + Shield Buff 護盾 Shape 形狀 Shape Builder 形狀建立 Shape Builder Regions 形狀建立區域 diff --git a/crates/ui-egui/src/script_runs.rs b/crates/ui-egui/src/script_runs.rs index 042e24cd3..02c222974 100644 --- a/crates/ui-egui/src/script_runs.rs +++ b/crates/ui-egui/src/script_runs.rs @@ -1,6 +1,7 @@ //! A running script, advanced one slice per frame: `script.run` starts it and [`poll`] takes it to //! its end, while the app goes on drawing the documents as they were before the run. The status bar -//! shows it with a Stop button; a run that has added a great deal waits for the person's answer. +//! shows it with a Stop button; a run that has added a great deal, or that opened a dialog with +//! `alert`, `confirm` or `prompt`, waits for the person's answer. use serde_json::json; @@ -14,21 +15,24 @@ pub fn poll(app: &mut VectorcraftApp) { if !app.session.script_running() { return; } - if app.session.script_waiting() { - if app.ui.dialog.is_none() { - app.ui.dialog = Some(Dialog::new(script_run::GROWTH_KIND, json!({}))); - } + script_run::settle_close(app); + if ask(app) { return; } let name = app.session.script_name().unwrap_or_default(); let cursor_settings = (app.session.caps_lock, app.session.prefs.use_precise_cursors); - let Some(result) = app.session.script_pump() else { return }; + let Some(result) = app.session.script_pump() else { + // The app draws no more frames while the run waits: its question opens in this one. + ask(app); + return; + }; if cursor_settings != (app.session.caps_lock, app.session.prefs.use_precise_cursors) { app.canvas.hover_cursor = None; } if app.ui.dialog.as_ref().is_some_and(|d| d.kind == script_run::GROWTH_KIND) { app.ui.dialog = None; } + script_run::settle_close(app); match result { Ok(_) => app.status(format!("Ran {name}")), Err(e) => failed(app, e.to_string()), @@ -36,6 +40,29 @@ pub fn poll(app: &mut VectorcraftApp) { app.sync_views(); } +/// Opens what the run waits at, once no other dialog is open: the script's own dialog, or the +/// question about its growth. Closes the script's dialog when the run no longer waits at it. +/// → true while the run waits. +fn ask(app: &mut VectorcraftApp) -> bool { + if let Some(dialog) = app.session.script_dialog() { + if app.ui.dialog.is_none() { + script_run::open_notice(app, &dialog); + } + return true; + } + // A run asked to stop no longer waits at its dialog. + if app.ui.dialog.as_ref().is_some_and(|d| d.kind == script_run::NOTICE_KIND) { + app.ui.dialog = None; + } + if app.session.script_waiting() { + if app.ui.dialog.is_none() { + app.ui.dialog = Some(Dialog::new(script_run::GROWTH_KIND, json!({}))); + } + return true; + } + false +} + /// Shows why a run ended without its changes. A stopped run that wrote no file says only that it /// was stopped; any other failure shows its error, which names the files left written. pub(crate) fn failed(app: &mut VectorcraftApp, error: String) { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index c8e60c14f..07d88595d 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -39,6 +39,7 @@ mod saveext; mod screenmode; mod screenshots; mod script_check; +mod script_dialogs; mod script_run; mod scripts_menu; mod scrub; diff --git a/crates/ui-egui/src/tests/script_dialogs.rs b/crates/ui-egui/src/tests/script_dialogs.rs new file mode 100644 index 000000000..b3750001a --- /dev/null +++ b/crates/ui-egui/src/tests/script_dialogs.rs @@ -0,0 +1,282 @@ +//! A script's `alert`, `confirm` and `prompt` in the app: the dialog the run waits at, its buttons, +//! Enter and Escape, Stop, another dialog in the way, quitting while it waits, and the same answers +//! given by a run with nobody to ask. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use crate::dialogs::script_run::{CLOSE_KIND, NOTICE_KIND}; +use crate::{VectorcraftApp, dialogs, menus, script_runs}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.session.prefs.show_external_script_warning = false; + app +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +const DRAW: &str = "vc.run('shape.rectangle', {x:0,y:0,width:10,height:10});"; + +fn start(app: &mut VectorcraftApp, source: &str) { + menus::run_ui_command(app, "ui.runScript", &json!({"source": source, "name": "d.js"})).unwrap().unwrap(); +} + +/// Polls the run until it ends, or until its dialog is open (true). +fn until_dialog(app: &mut VectorcraftApp) -> bool { + for _ in 0..10_000 { + if !app.session.script_running() { + return false; + } + script_runs::poll(app); + if app.ui.dialog.as_ref().is_some_and(|d| d.kind == NOTICE_KIND) { + return true; + } + } + panic!("the script neither ended nor opened a dialog"); +} + +fn answer(app: &mut VectorcraftApp, answer: &str) { + app.ui.dialog.as_mut().unwrap().fields.insert("answer".into(), json!(answer)); + dialogs::confirm(app).unwrap(); +} + +thread_local! { + // One context per test, so frames follow one another as in the app. + static CTX: egui::Context = { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx + }; +} + +/// One headless frame of the dialog layer and the shortcuts, in a window of `size`. Each key pressed +/// in it is let go in a second frame, as a person lets go of it. +fn frame(app: &mut VectorcraftApp, events: Vec, size: egui::Vec2) { + let ctx = CTX.with(Clone::clone); + let released: Vec = events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, physical_key, modifiers, pressed: true, .. } => { + Some(egui::Event::Key { key: *key, physical_key: *physical_key, pressed: false, repeat: false, modifiers: *modifiers }) + } + _ => None, + }) + .collect(); + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)); + let pressed = !released.is_empty(); + for events in std::iter::once(events).chain(pressed.then_some(released)) { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + dialogs::show(app, ui.ctx()); + }); + out.textures_delta.clear(); + } +} + +fn key(k: egui::Key) -> egui::Event { + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() } +} + +const WINDOW: egui::Vec2 = egui::vec2(1200.0, 800.0); + +fn log(app: &VectorcraftApp) -> Value { + app.session.script_report().unwrap()["log"].clone() +} + +#[test] +fn alert_waits_and_stop_rolls_back_first_dialog() { + for stop_with_escape in [false, true] { + let mut app = app(); + start(&mut app, &format!("{DRAW} while (1) {{ alert('x'); }}")); + assert!(until_dialog(&mut app)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.fields["kind"].as_str(), d.fields["message"].as_str(), d.fields["title"].clone()), (Some("alert"), Some("x"), Value::Null)); + for _ in 0..5 { + script_runs::poll(&mut app); + } + assert!(app.session.script_running(), "the run waits at the dialog"); + assert_eq!(objects(&app), 0); + if stop_with_escape { + frame(&mut app, vec![key(egui::Key::Escape)], WINDOW); + } else { + answer(&mut app, "stop"); + } + assert!(app.ui.dialog.is_none()); + assert!(!until_dialog(&mut app)); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); + assert_eq!(app.session.script_report().unwrap()["dialogs"][0]["answer"], "stop"); + } +} + +#[test] +fn confirm_enter_uses_no_as_default() { + for (no_as_default, want) in [(true, false), (false, true)] { + let mut app = app(); + start(&mut app, &format!("vc.log(confirm('Go?', {no_as_default}));")); + assert!(until_dialog(&mut app)); + frame(&mut app, vec![], WINDOW); + frame(&mut app, vec![key(egui::Key::Enter)], WINDOW); + assert!(app.ui.dialog.is_none(), "Enter answers the dialog: noAsDflt {no_as_default}"); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!([want]), "noAsDflt {no_as_default}"); + } + let mut app = app(); + start(&mut app, "vc.log(confirm('Go?'));"); + assert!(until_dialog(&mut app)); + frame(&mut app, vec![key(egui::Key::Escape)], WINDOW); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!([false]), "Escape is No"); +} + +#[test] +fn prompt_ok_and_cancel_return_values() { + let mut app = app(); + start(&mut app, "vc.log(prompt('Name', 'pre', 'Ask')); vc.log(prompt('Again', 'x'));"); + assert!(until_dialog(&mut app)); + assert_eq!(dialogs::script_run::notice_heading(app.ui.dialog.as_ref().unwrap()), "Ask"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["value"], "pre"); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("typed")); + dialogs::confirm(&mut app).unwrap(); + assert!(until_dialog(&mut app)); + dialogs::cancel(&mut app); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!(["typed", null])); + let mut app = self::app(); + start(&mut app, "vc.log(prompt('Name', ''));"); + assert!(until_dialog(&mut app)); + let e = { + app.ui.dialog.as_mut().unwrap().fields.insert("answer".into(), json!("yes")); + dialogs::confirm(&mut app).unwrap_err() + }; + assert!(e.contains("ok, cancel, stop"), "{e}"); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND, "a wrong answer leaves the dialog open"); + answer(&mut app, "ok"); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!([""]), "OK with an empty field gives an empty string"); +} + +#[test] +fn the_dialog_opens_in_the_frame_the_run_waits() { + let mut app = app(); + start(&mut app, "alert('x');"); + script_runs::poll(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(NOTICE_KIND), "no later frame is drawn while the run waits"); +} + +#[test] +fn a_prompt_text_past_the_limit_keeps_the_dialog_open() { + let mut app = app(); + start(&mut app, "vc.log(prompt('Name', ''));"); + assert!(until_dialog(&mut app)); + let long = "x".repeat(vectorcraft_engine::script::dialogs::MAX_PROMPT_BYTES + 1); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!(long)); + assert!(dialogs::confirm(&mut app).is_err()); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND, "the refused answer leaves the dialog open"); + assert!(app.session.script_dialog().is_some(), "the run still waits"); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("ok")); + answer(&mut app, "ok"); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!(["ok"])); +} + +#[test] +fn dialog_wait_survives_another_modal() { + let mut app = app(); + start(&mut app, "alert('x');"); + script_runs::poll(&mut app); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::GROWTH_KIND, json!({}))); + let other = app.ui.dialog.clone(); + for _ in 0..3 { + script_runs::poll(&mut app); + } + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.clone()), other.map(|d| d.kind), "the open dialog stays"); + assert!(app.session.script_running()); + app.ui.dialog = None; + assert!(until_dialog(&mut app), "the script's dialog opens once the other one closes"); +} + +#[test] +fn close_while_notification_waits_abandons_or_resumes() { + let mut app = app(); + start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));")); + assert!(until_dialog(&mut app)); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("half")); + dialogs::script_run::ask_to_close(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND); + script_runs::poll(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND, "the run goes on waiting"); + dialogs::cancel(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["value"].as_str()), (NOTICE_KIND, Some("half")), "Cancel shows the dialog again as it was"); + dialogs::script_run::ask_to_close(&mut app); + dialogs::confirm(&mut app).unwrap(); + assert!(!app.session.script_running()); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); +} + +#[test] +fn close_question_ends_with_a_run_stopped_another_way() { + let mut app = app(); + start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));")); + assert!(until_dialog(&mut app)); + dialogs::script_run::ask_to_close(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND); + assert_eq!(app.run("script.stop", json!({})).unwrap()["stopped"], true); + script_runs::poll(&mut app); + assert!(!app.session.script_running()); + assert!(app.ui.dialog.is_none(), "the question about a run that has ended closes"); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none(), "Cancel does not show the stopped script's dialog again"); + assert_eq!(objects(&app), 0); + // Asked to stop, the run no longer waits at its dialog before its next slice ends it. + let mut app = self::app(); + start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));")); + assert!(until_dialog(&mut app)); + dialogs::script_run::ask_to_close(&mut app); + app.run("script.stop", json!({})).unwrap(); + dialogs::script_run::settle_close(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND, "the run has not ended yet"); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none(), "Cancel does not show a dialog the run no longer waits at"); +} + +#[test] +fn notification_layout_with_long_text_and_small_window() { + let mut app = app(); + let long = "word ".repeat(3_000); + start(&mut app, &format!("alert({long:?} + '\\u0007\\r\\n', 'T\\nitle', true);")); + assert!(until_dialog(&mut app)); + for _ in 0..3 { + frame(&mut app, vec![], egui::vec2(320.0, 240.0)); + } + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND); + assert_eq!(dialogs::script_run::notice_heading(app.ui.dialog.as_ref().unwrap()), "T\u{240a}itle"); + answer(&mut app, "ok"); + assert!(!until_dialog(&mut app)); + assert_eq!(app.session.script_report().unwrap()["dialogs"][0]["errorIcon"], true); +} + +#[test] +fn the_app_and_a_run_with_nobody_to_ask_give_the_same_results() { + let source = "vc.log(confirm('a', true, 'T')); vc.log(prompt('b', 'p')); alert('c', undefined, false); vc.log('done');"; + let mut app = app(); + start(&mut app, source); + for a in ["yes", "ok", "ok"] { + assert!(until_dialog(&mut app)); + answer(&mut app, a); + } + assert!(!until_dialog(&mut app)); + let framed = app.session.script_report().unwrap().clone(); + let mut s = Session::new(); + let given = json!([{"kind": "confirm", "value": true}, {"kind": "prompt", "value": "p"}, {"kind": "alert", "value": "ok"}]); + let headless = s.execute("script.run", &json!({"source": source, "name": "d.js", "answers": given})).unwrap(); + assert_eq!(framed["log"], headless["log"]); + assert_eq!(framed["dialogs"], headless["dialogs"]); + assert_eq!(framed["log"], json!([true, "p", "done"])); +} diff --git a/docs/dimensions.md b/docs/dimensions.md index bcd08c3b8..6f11a29c9 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -74,14 +74,27 @@ paths and text as ordinary art. Object › Expand, Object › Expand Appearance, and Object › Path › Outline Stroke turn a dimension whose art they change into a plain group of that art, also when they reach only a part of it: moving or scaling the group does not redraw it, it does not follow the art it measured, and undoing the command gives the live dimension back. The menus dim -Object › Expand… while a whole dimension is selected, because its label has appearance items; -Object › Expand Appearance and the `object.expand` command still reach it. A dimension selected with -the art it measures also becomes a plain group when Object › Expand, Object › Expand Appearance or -Object › Path › Outline Stroke changes only that art and the art no longer fits the dimension's link, -for example because its number of anchors changed. When the art keeps its id and anchor count and the -dimension's own art does not change, the dimension stays live and its label shows the new length. Pasted -dimensions keep their geometry and drop their links, so they never bind to unrelated art in another -document. +Object › Expand… while a whole dimension is selected without the art it measures, because its label +has appearance items; Object › Expand Appearance and the `object.expand` command still reach it. + +When a whole dimension is selected together with art it measures, directly or in selected groups +without fills, strokes or effects of their own, Object › Expand, Object › Expand Appearance and +Object › Path › Outline Stroke leave the dimension's lines and label as they are and change only the +other art. The menus do not dim Object › Expand… for the label of such a dimension. If the new art no +longer fits the dimension's link, for example because its number of anchors changed, the dimension +stays where it was, no longer linked, as it does when its art is deleted. Undoing the command links +it again. When the art keeps its id and anchor count, the dimension stays linked and its label shows +the new length. When the dimension is all the command would change, the command changes nothing and +reports the error it gives for a selection with nothing to change: "Expand: nothing to expand", +"Expand Appearance: the selection has a basic appearance" or "Outline Stroke: no stroked paths +selected". A selected group with fills, strokes or effects of its own changes everything it holds +when its appearance expands, its dimensions included: a geometry effect is baked into their lines +and label, which become a plain group, and a blur, feather or inner glow turns them into part of the +image that replaces the group's art. Type › Create Outlines still outlines the label of a dimension +selected with its art, which turns the dimension into a plain group. + +Pasted dimensions keep their geometry and drop their links, so they never bind to unrelated art in +another document. ## Commands diff --git a/docs/gaps.md b/docs/gaps.md index 78ee236cd..3716be392 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 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-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) ## 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) | 9–19 | no | [target-app-parity.md](target-app-parity.md) | +| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 7–17 | 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) | @@ -649,14 +649,15 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Direct Selection of a path, compound path or type inside a dynamic instance also takes opacity and blend (`transparency.set`) and a gradient or pattern fill or stroke (`paint.setFill` / `paint.setStroke`, Eyedropper), for that instance only; - graphic style libraries (Window › Graphic Style Libraries; seven original libraries generated in code, including - Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert; User Defined; Other + graphic style libraries (Window › Graphic Style Libraries; eight original libraries generated in code, including + 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 seven shipped, and further symbol libraries beyond Basic +- **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; 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:** 7–17 h. +- **Estimate:** 6–16 h. ## G3 3D and Materials @@ -856,6 +857,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 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. | | 2026-11-10 | minor | Tidy-up, no content change: in Feature submission evidence, a stray table row and two "PR #NNNN revision" notes move into Feature revisions, the three "Feature evidence last updated" lines become one, the duplicate Flyout update note goes (its row stays), and eight reconciliation rows become two. Scores and estimates unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..1b165aa43 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1569,6 +1569,8 @@ app through the global object `vc`, and writes to the Script Console and runs ot missing file and a refused path throw errors the script can catch. - `$.fileName` is the file being run, which is the file `$.evalFile` is running while it runs, and is empty for a script given as `source`. `$.includePath` is the current `#includepath` list, separated by `;`. +- `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and `prompt(message, preset[, title])` + open a dialog, described below. `vc.api.has("dialogs")` is true. - `vc.args` is the `args` object. - `vc.api.version` is `"1.0"`, and `vc.api.has(feature)` says whether the API has a feature. @@ -1634,16 +1636,18 @@ VectorCraft sets these limits itself: | `args` as JSON | 1 MiB | | Log | 10,000 entries and 4 MiB | | Script Console | 2,048 lines and 1 MiB of text; 4,096 bytes a line; 256 bytes of a script's name | +| 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 | | 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 `ui.dialog.cancel` all stop the run. A run with nobody to ask, such as a call over MCP or the command line, ends with an error at that limit. The headless reply is `{ok: true, name, changed, written, log, fuelUsed, hostCalls, -runTimeMs, timingAvailable, hostCallCounts, slowestCommands, console}`. In the app the reply is `{running: true, -name}`, and the report appears later as `scriptReport` in `ui.inspect`. The report of a failed run has the same -fields after `kind`, `message`, `line`, `column`, `file` (when the error is in another file than the script) and -`fuelLeft`. `answers` takes only an empty object, since scripts -have no prompts yet. +runTimeMs, timingAvailable, hostCallCounts, slowestCommands, console, dialogs, dialogCount, dialogsTruncated}`. In +the app the reply is `{running: true, name}`, and the report appears later as `scriptReport` in `ui.inspect`. The +report of a failed run has the same fields after `kind`, `message`, `line`, `column`, `file` (when the error is in +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. @@ -1651,6 +1655,11 @@ have no prompts yet. - `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. +- `dialogs` lists the run's dialogs in the order they opened, as `{kind, message, title, errorIcon, answer}` for + `alert`, `{kind, message, title, noAsDefault, answer}` for `confirm` and `{kind, message, title, preset, answer}` + for `prompt`. `answer` is `"ok"`, `true`, `false`, the prompt's text, `null` for Cancel, `"stop"` for a dialog the + run ended at, or `"missing"` for a dialog `answers` had no answer for. `dialogCount` counts every dialog, and + `dialogsTruncated` is true when the list stopped at its limit. ```json {"name":"run_command","arguments":{"command":"script.run","params":{"source":"vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10});"}}} @@ -1660,6 +1669,47 @@ have no prompts yet. vectorcraft-cli run --cmd script.run --params '{"path": "Tidy Layers.js", "args": {"margin": 12}}' ``` +The help page https://extendscript.docsforadobe.dev/extendscript-tools-features/user-notification-dialogs/ +describes the three dialog functions. A script calls them as globals: + +- `alert(message[, title, errorIcon])` shows the message with OK, and an error icon when `errorIcon` is true. It + returns `undefined`. +- `confirm(message[, noAsDflt, title])` shows the message with Yes and No. Yes is the default button, and No is the + default when `noAsDflt` is true. It returns `true` for Yes and `false` for No. +- `prompt(message, preset[, title])` shows the message and a text field that starts with `preset`, with OK and + Cancel. It returns the field's text for OK, an empty string included, and `null` for Cancel. + +The message, the preset and a given title are read as `String(value)` gives them, and `errorIcon` and `noAsDflt` as +`Boolean(value)` gives them. A title that is left out or `undefined` is Script Alert. A call without a message, or a +`prompt` without a preset, throws a `TypeError`. A message or a prompt's preset longer than 16 KiB, or a title longer than +256 bytes, throws a `RangeError` and opens no dialog. The dialog shows line feeds, carriage returns and the two +together as line breaks, and other control characters as their Unicode Control Pictures. The title stays on one +line, with line breaks shown as Control Pictures too. + +In the app, the run waits at the call until the person answers, and spends no instructions while it waits. Enter +gives the default button's answer. Escape gives Cancel for a prompt, No for a confirm and Stop for an alert. Every +dialog also has Stop, which ends the run as `script.stop` does and puts back every document. A dialog can open only +from the script's own code: called inside a callback of a built-in function such as `Array.prototype.forEach`, a +getter, a `toString` conversion or a file `$.evalFile` runs, it throws an `Error`. The code of an `#include` file +is part of the script's own code, and its dialogs open as the script's do. Quitting while a dialog waits asks whether to stop the +script, and Cancel shows the dialog again with the text typed so far. In the app, `ui.dialog.confirm` answers the +open dialog (kind `scriptDialog`) with its `answer` field (`ok`, `yes`, `no`, `cancel` or `stop`), or with the +default button's answer when `answer` is left out. A prompt's `value` longer than 16 KiB is an error, and the +dialog stays open. + +A run with nobody to ask takes its answers from `answers`, a list of `{kind: "alert", value: "ok"}`, `{kind: +"confirm", value: true}` or `{kind: "confirm", value: false}`, and `{kind: "prompt", value: text}` or `{kind: +"prompt", value: null}` for Cancel. Each dialog takes the next answer in order, callbacks of built-in functions +included. The run ends with an error that the script cannot catch, and every document goes back, when no answer is +left, when the next answer is for another kind of dialog, or when the script ends with answers it did not use. +`script.run` refuses a list that is malformed, longer than 10,000 answers or larger than 1 MiB before the run starts, +and refuses any answers for a run in the app, where the person answers. An omitted `answers`, an empty list and an +empty object are the same. + +```sh +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"}]}' +``` + 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 @@ -2967,7 +3017,7 @@ as a value left out. ## Graphic style libraries Graphic style libraries are read-only sets of graphic styles generated in code (Shadows and Glows, Outlines and -Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD). `graphicStyle.libraries` lists them +Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD, Status Effects). `graphicStyle.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `graphicStyle.library {library}` returns one's styles in the `graphicStyle.list` shape (`library` is an id or a name). `graphicStyle.addFromLibrary {library, name? | names?}` copies styles into the document's Graphic Styles as one undo step (no names: the whole library); styles the @@ -3602,7 +3652,10 @@ holds symbol instances that `object` expands, whether only their art holds it, w `object` on), and `object.mesh.create` on a gradient-filled object colours the mesh points as the gradient paints them, with the colors Object › Expand gives them. `object.expand`, `effect.expandAppearance`, `type.createOutlines` and `object.path.outlineStroke` turn a dimension whose -art they change into a plain group of that art, in the same undo step (see [dimensions.md](dimensions.md)). +art they change into a plain group of that art, in the same undo step. `object.expand`, `effect.expandAppearance` and +`object.path.outlineStroke` leave a dimension selected with art it measures, directly or in groups without fills, +strokes or effects of their own, as it is and change only the other art; if the new art no longer fits the dimension's +link, the dimension stays where it was, no longer linked (see [dimensions.md](dimensions.md)). `object.expand` rejects a param of the wrong type, such as `"fill": "yes"`, `"gradient": 3` or `"steps": "16"`, and changes nothing. Omitting a param or giving null keeps its default. diff --git a/docs/roadmap.md b/docs/roadmap.md index 02e0a61fc..82362eb94 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: 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: 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). > @@ -189,6 +189,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 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. | | 2026-11-10 | minor | M10/G5: Game HUD built-in graphic style library; estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8aeae51f1..481fb322f 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 (ruler double-click adds a guide at the pointer, #5239; 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 (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). > @@ -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 | ~40% | 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 seven (including Game HUD) 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 | ~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… | | **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 | @@ -567,6 +567,7 @@ since 2026-10-01. **Drawing tools, Star inner radius held with Ctrl/Cmd:** While a star is dragged out, Ctrl/Cmd keeps the inner radius the star showed when the key went down, also when the key is pressed with the pointer still. `a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt` and `a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down` cover it, with Alt's straight shoulders and a drag canceled with Escape. **Drawing tools, Dimension tool horizontal and vertical linear dimensions:** A linear dimension between two clicked points or dragged out from a segment is aligned, horizontal or vertical, chosen by where the pointer moves while it is placed, and a click again by the second point gives aligned; `dimension.create` and `dimension.update` take `orientation`, and the dimension stays linked to the anchors it measures, keeps its orientation when it or the art turns, with a line that lay outside the points kept outside them; an unlinked horizontal or vertical dimension that is reflected has its line on the reflected side. `the_pointer_chooses_aligned_horizontal_or_vertical`, `two_points_measure_horizontally_vertically_or_aligned`, `clicking_again_by_the_second_point_places_an_aligned_dimension`, `a_segment_drag_measures_horizontally_vertically_or_aligned`, `points_on_a_vertical_line_measure_aligned`, `horizontal_and_vertical_dimensions_draw_lines_along_their_axis`, `turned_horizontal_dimensions_stay_horizontal`, `a_linked_horizontal_dimension_keeps_its_gap_from_turned_art`, `a_linked_horizontal_dimension_below_or_between_its_points_follows_turned_art`, `a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side`, `a_dimension_without_an_orientation_reads_as_aligned`, `dimension_orientation_is_checked` and `a_turned_dimension_keeps_its_line_on_its_side` cover it. **Drawing tools, Dimension tool points that are not anchors follow the art:** A clicked point on a segment stays at its place along the segment, also when the segment curves, and a point off the art is linked to anchors (or, when the anchors lie on one line, anchors and handles) of the path, so both ends of the dimension follow when the art is moved, scaled, turned or reflected, also when the point was placed by a Smart Guide and when the dimension is saved and opened again; a linked linear dimension of reflected art (other than art on one line) has its line on the reflected side; deleting the art, changing its anchor count or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves the dimension where it was, no longer linked, instead of removing it. `a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends`, `a_dimension_to_a_point_off_the_art_moves_with_the_art`, `a_dimension_whose_art_changes_its_anchors_stays_where_it_was`, `a_dimension_whose_art_is_opened_stays_where_it_was`, `angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were`, `a_dimension_that_cannot_be_drawn_again_keeps_its_art`, `a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side`, `a_dimension_to_a_point_off_a_line_follows_the_line`, `a_point_off_an_arch_is_reflected_with_the_arch`, `a_point_beside_an_almost_straight_curve_follows_it`, `a_point_off_a_loop_with_its_anchors_in_one_place_follows_it`, `a_point_beside_a_reflected_line_moves_as_a_half_turn_would`, `a_point_on_a_segment_stays_on_it_when_the_segment_curves`, `a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side`, `a_dimension_of_art_reflected_across_its_points_changes_sides`, `a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset`, `free_points_survive_a_native_file`, `dimension_free_points_are_checked`, `points_off_anchors_are_held_against_the_path` and `a_point_smart_guides_place_is_held_against_the_path` cover it. +**Drawing tools, Dimension tool dimensions selected with their art keep their lines and label:** Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave a dimension selected with art it measures, directly or in selected groups without fills, strokes or effects of their own, as it is and change only the other art; when the new art no longer fits its link, the dimension stays where it was, no longer linked, and undo links it again. `expand_leaves_a_dimension_selected_with_its_art_as_it_is`, `expand_appearance_leaves_a_dimension_selected_with_its_art_as_it_is`, `outline_stroke_leaves_a_dimension_selected_with_its_art_as_it_is`, `a_dimension_selected_with_art_that_has_nothing_to_expand_changes_nothing`, `a_group_effect_expanded_reaches_the_dimension_it_holds`, `expand_keeps_a_dimension_in_its_asset_and_its_variable_binding`, `a_dimension_keeps_no_link_to_the_art_expand_replaced`, `a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced`, `a_dimension_outlasts_the_stroke_expand_outlined` and `expand_appearance_in_safe_mode_leaves_a_dimension_selected_with_its_art` cover it. **Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling. **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:** Shared existing preset definitions are reused; no external preset assets are added. Presets preserve each selected artboard's orientation, and orientation choices apply even when the active board already matches. Fit operations draft bounds until OK, affect only the active artboard and leave artwork in place. Regression coverage checks draft/Cancel, selected-set application, active-only fitting and Undo. @@ -733,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: 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, and `#strict on`, `#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 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`. + - 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 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). @@ -1317,7 +1319,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. - - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; seven generated style libraries (including Game HUD) save and load as `.vcstyles`. + - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; eight generated style libraries (including Game HUD and Status Effects) save and load as `.vcstyles`. In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. - **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Load text styles from another document:** the Character Styles and Paragraph Styles panel menus have Load Character Styles… or Load Paragraph Styles… and Load All Styles…, which copy the styles of a document picked in an open dialog, with the swatches and patterns they paint with, in one undo step (`charStyle.load`, `paraStyle.load`, `textStyle.loadAll`); a style the document already has, under its name or a numbered one, is not added again, any other whose name is taken gets a number, and the file's Normal styles are not loaded. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). @@ -1965,6 +1967,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 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. | | 2026-11-10 | minor | File formats: in a `.ai` or EPS opened from its editing data, an envelope of type opens live where it draws as its drawn art and the layers with it draw as the page (the envelope weighed as the art it draws); elsewhere, and where a text or visibility variable binds its type, it keeps its drawn art, with a note (#3179); evidence updated; scores and hours unchanged. |