Merge remote-tracking branch 'origin/main' into fix/6502-ai-layer-colour-index

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
ruchirguitar
2026-11-11 17:52:50 +05:30
133 changed files with 6684 additions and 442 deletions
Generated
+1
View File
@@ -6687,6 +6687,7 @@ dependencies = [
"vectorcraft-geom",
"wasmi",
"wat",
"web-time",
]
[[package]]
+21 -3
View File
@@ -136,9 +136,11 @@ impl crate::Document {
}
}
/// The character style a graph's labels keep through regeneration once formatted (Format the text in graphs): the
/// value axis numbers, the category labels (category axis, radar spokes, pie names) and the legend labels (beside or
/// across the top, or in pie wedges). `None` draws the default 9 pt black.
/// The character style and anti-aliasing method a graph's labels keep through regeneration once formatted (Format
/// the text in graphs): the value axis numbers, the category labels (category axis, radar spokes, pie names) and the
/// legend labels (beside or across the top, or in pie wedges). `None` draws the default 9 pt black. The `*_para`
/// fields are the paragraph attributes each kind keeps the same way (`None`: the defaults); the graph still sets each
/// label's alignment.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LabelStyles {
@@ -148,6 +150,22 @@ pub struct LabelStyles {
pub categories: Option<crate::CharStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legend: Option<crate::CharStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub values_para: Option<crate::ParaStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub categories_para: Option<crate::ParaStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legend_para: Option<crate::ParaStyle>,
/// The anti-aliasing method the value axis numbers keep once set (Character panel › Anti-aliasing Method).
/// `None` draws them Sharp.
#[serde(skip_serializing_if = "Option::is_none")]
pub values_anti_alias: Option<crate::text::TypeAntiAlias>,
/// The anti-aliasing method the category labels keep once set. `None` draws them Sharp.
#[serde(skip_serializing_if = "Option::is_none")]
pub categories_anti_alias: Option<crate::text::TypeAntiAlias>,
/// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp.
#[serde(skip_serializing_if = "Option::is_none")]
pub legend_anti_alias: Option<crate::text::TypeAntiAlias>,
}
impl LabelStyles {
+42
View File
@@ -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<GraphicStyle> {
]
}
/// Status Effects: buffs and debuffs for RPG/gamedev art (original, catalogue stylize effects only).
fn status_effects() -> Vec<GraphicStyle> {
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]
+1 -1
View File
@@ -395,7 +395,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
if !kept {
return None;
}
reshape_leaves(&mut m, &fx, Some(d));
reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d));
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
m.appearance.items.clear();
m.appearance.contents_index = None;
+17 -3
View File
@@ -26,6 +26,10 @@ pub struct GeomContext<'a> {
/// The document that the swatches and patterns named by a live-effect plug-in that paints its
/// pieces are looked up in. Without one, a result that names any is refused.
pub doc: Option<&'a Document>,
/// The object, fill or stroke and effect index a deferred live-effect plug-in is listed by
/// ([`vectorcraft_plugins::deferred`]). When `None`, it is the object `seed` names, `item` and
/// the effect's index in the list being applied.
pub stage: Option<vectorcraft_plugins::deferred::Stage>,
}
impl<'a> GeomContext<'a> {
@@ -36,7 +40,17 @@ impl<'a> GeomContext<'a> {
_ => FillRule::NonZero,
};
let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0);
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None }
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None, stage: None }
}
/// The context of effect `e`, one of `list`: unless [`Self::stage`] is set, a live-effect
/// plug-in there is listed by `e`'s index in `list`.
pub(crate) fn at(self, list: &[Effect], e: &Effect) -> Self {
let stage = self.stage.or_else(|| {
let effect = list.iter().position(|x| std::ptr::eq(x, e))?;
Some(vectorcraft_plugins::deferred::Stage { object: self.seed, effect, item: self.item })
});
Self { stage, ..self }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
@@ -335,7 +349,7 @@ pub(crate) fn apply_geometry_carrying_within(
}
let p = merged_params(&e.id, &e.params);
let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path)));
if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) {
if let Some(next) = apply_one(&e.id, &p, &cur, b, &ctx.at(effects, e), limit) {
if let Some(c) = carry.as_deref_mut() {
c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed));
}
@@ -377,7 +391,7 @@ fn builds_within(id: &str) -> bool {
fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData {
use util::{join, num};
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item, at: ctx.stage };
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints, ctx.doc).unwrap_or_else(|| path.clone());
}
match id {
+3 -3
View File
@@ -209,7 +209,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nod
Some(area) => Some(reshaped(&area, n, doc)),
None => {
let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc);
// The group's own fills and strokes follow the effects as its members do.
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
@@ -225,7 +225,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nod
let art = h(n).filter(|art| nodes.charge(art))?;
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(doc, &mut art, false);
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, doc);
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc);
// The art's own fills and strokes follow the effects as its members do.
if carry.moves(&art.appearance) {
carry.apply(&mut art.appearance);
@@ -738,7 +738,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
}
if !fx.is_empty() {
m = crate::reshape::outlined_members(doc, &m);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc);
// The group's own fills and strokes paint the members as a whole: their gradients follow
// the effects as the members' do.
if paint && carry.moves(&m.appearance) {
+3 -2
View File
@@ -161,7 +161,8 @@ pub fn any_plugin_paints_output() -> bool {
vectorcraft_plugins::registry::list().iter().any(|p| vectorcraft_plugins::effect::paints_output(&p.manifest().id))
}
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
/// Changes whenever a plug-in is installed or removed (the registry's revision), and when deferred
/// live-effect plug-in stages give results ([`vectorcraft_plugins::deferred::revision`]).
pub fn plugin_revision() -> u64 {
vectorcraft_plugins::registry::revision()
vectorcraft_plugins::registry::revision() ^ vectorcraft_plugins::deferred::revision().rotate_left(32)
}
+52 -28
View File
@@ -573,7 +573,7 @@ fn as_art_opts(
}
_ => outline_art_with(symbols, paint_doc, n, symbol_art, outline, nodes, apart)?,
};
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, paint_doc);
reshape_leaves_apart(&mut art, &fx, &n.appearance.effects, Budget::EFFECTS, apart, paint_doc);
if apart {
hoist_copies(&mut art);
}
@@ -636,21 +636,22 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns
/// that live-effect plug-ins painting their pieces name are looked up in `doc`
/// ([`reshape_leaves_apart`]).
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) {
reshape_leaves_within(art, fx, Budget::EFFECTS, doc);
/// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one
/// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]).
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) {
reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc);
}
/// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and
/// strokes) follow the effects ([`crate::Carry`]).
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) -> crate::Carry {
reshape_leaves_within(art, fx, Budget::EFFECTS, doc)
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) -> crate::Carry {
reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc)
}
/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost.
#[cfg(test)]
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget, doc: Option<&Document>) -> crate::Carry {
reshape_leaves_apart(art, fx, budget, false, doc)
reshape_leaves_apart(art, fx, &[], budget, false, doc)
}
/// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding
@@ -669,12 +670,15 @@ fn hoist_copies(art: &mut Node) {
}
}
/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects
/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost; with
/// `apart`, a Transform effect's copies of each path are objects
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect
/// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]),
/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused).
/// Returns how gradients that paint the art as a whole follow the effects.
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
/// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect
/// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when
/// `owner` does not hold it.
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
let mut whole = crate::Carry::default();
if fx.is_empty() {
return whole;
@@ -693,6 +697,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
});
let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
let style_safe = leaves_safe && !art_stroke;
let listed = fx;
let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe);
let fx = opt_fx.as_slice();
let seed = art.id.0;
@@ -703,12 +708,15 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
// Where a live-effect plug-in sits, which it is listed by when it is deferred.
let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e)));
if let Some(plugin) = painted_plugin(e) {
let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
for_each_leaf(art, &mut |leaf| {
let Some((path, _)) = leaf_geometry(leaf) else { return };
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
if let Some(made) = paint_leaf(leaf, plugin, &params, bounds, room, doc) {
if let Some(made) = paint_leaf(leaf, plugin, &params, bounds, room, doc, at) {
used = used.minus(before).plus(made);
}
});
@@ -778,7 +786,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
return;
}
}
let Some(out) = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else {
let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
let Some(out) = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else {
kept_ids.insert(leaf.id);
return;
};
@@ -1056,10 +1065,18 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling
/// its own ([`vectorcraft_plugins::painted::appearance`]). The swatches and patterns the pieces
/// name must be in `doc`, and without one the pieces may name none. Returns what the pieces cost
/// ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, the pieces are
/// refused, or they would pass `budget`.
fn paint_leaf(leaf: &mut Node, plugin: &str, params: &serde_json::Value, bounds: Rect, budget: Budget, doc: Option<&Document>) -> Option<Budget> {
/// refused, or they would pass `budget`. A deferred run is listed by `at`.
fn paint_leaf(
leaf: &mut Node,
plugin: &str,
params: &serde_json::Value,
bounds: Rect,
budget: Budget,
doc: Option<&Document>,
at: Option<vectorcraft_plugins::deferred::Stage>,
) -> Option<Budget> {
let (path, rule) = leaf_geometry(leaf)?;
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None };
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None, at };
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
if !budget.fits(made) {
@@ -1097,18 +1114,18 @@ fn piece_cost(path: &PathData) -> Budget {
/// other fills and strokes; one that gives none keeps the leaf's. Returns
/// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails,
/// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass
/// `budget`.
/// `budget`. A deferred run is listed by `at`.
fn paint_piece(
leaf: &mut Node,
item: &AppearanceItem,
plugin: &str,
params: &serde_json::Value,
(plugin, params): (&str, &serde_json::Value),
bounds: Rect,
budget: Budget,
doc: Option<&Document>,
at: Option<vectorcraft_plugins::deferred::Stage>,
) -> Option<Budget> {
let (path, rule) = leaf_geometry(leaf)?;
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0) };
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0), at };
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
if !budget.fits(made) {
@@ -1241,6 +1258,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
};
let Some(tb) = g.bounds() else { continue };
let params = merged_params(&fx[at].id, &fx[at].params);
// Where each effect the piece keeps sits among the item's effects.
let kept: Vec<usize> = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect();
if let Some(plugin) = painted_plugin(&fx[at]) {
let mut it = item.clone();
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
@@ -1250,7 +1269,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
carry.apply(&mut one);
}
let it = one.items.pop().unwrap_or(it);
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i) };
let stage = vectorcraft_plugins::deferred::Stage { object: ctx.seed, effect: at, item: Some(i) };
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i), at: Some(stage) };
let painted = vectorcraft_plugins::effect::apply_painted(plugin, &params, &g, tb, rule, paints, doc)
.map(|pieces| pieces.iter().map(|p| (p.path.clone(), p.rule, vectorcraft_plugins::painted::items(&it, p))).collect::<Vec<_>>())
.filter(|pieces| room.fits(pieces.iter().map(|(p, ..)| piece_cost(p)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus)));
@@ -1264,7 +1284,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
group.push(Arc::new(p));
}
let mut p = Node::group(leaf.id, group);
transform_piece(&mut p, ctx.seed, limit, used, doc);
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
pieces.push(Arc::new(p));
continue;
}
@@ -1281,7 +1301,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
set_geometry(&mut p, g.clone(), rule);
let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room);
*used = used.plus(made);
transform_piece(&mut p, ctx.seed, limit, used, doc);
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
pieces.push(Arc::new(p));
}
if !plain.is_empty() {
@@ -1297,14 +1317,17 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
/// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a
/// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece:
/// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's
/// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of
/// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of
/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with
/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does.
/// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path
/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and
/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`.
fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: Option<&Document>) {
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source,
/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the
/// piece was made from, and the index among that item's effects of each effect the piece kept,
/// which a deferred live-effect plug-in is listed by.
fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) {
let mut count = 0;
for_each_leaf(p, &mut |l| {
if let Some(item) = l.appearance.items.first() {
@@ -1320,8 +1343,9 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc:
// What the other paths leave of the budget.
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) };
if let Some(plugin) = painted_plugin(&e) {
if let Some(made) = paint_piece(leaf, &item, plugin, &merged_params(&e.id, &e.params), bounds, room, doc) {
if let Some(made) = paint_piece(leaf, &item, (plugin, &merged_params(&e.id, &e.params)), bounds, room, doc, Some(stage)) {
*used = used.minus(before).plus(made);
}
return;
@@ -1340,7 +1364,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc:
AppearanceItem::Stroke(s) => Some(s),
AppearanceItem::Fill(_) => None,
};
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc };
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc, stage: Some(stage) };
let mut carry = crate::Carry::default();
let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry));
*used = used.minus(before).plus(Budget::of(&out));
+96 -3
View File
@@ -21,8 +21,8 @@ use serde_json::{Map, Value, json};
use vectorcraft_brush::Brush;
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::{
Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration,
TextStyleDef,
Appearance, AppearanceItem, Document, Effect, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice,
SymbolRegistration, TextStyleDef,
};
use super::super::brushsym::{set_natural_size, stored_natural_size};
@@ -140,6 +140,31 @@ fn node_refs(n: &Node, f: Visit) {
});
}
/// The `(name, xml)` of `e` when it is an SVG filter effect.
fn filter_of(e: &Effect) -> Option<(String, String)> {
let text = |k: &str| e.params.get(k).and_then(Value::as_str).map(str::to_string);
if e.id != Appearance::SVG_FILTER {
return None;
}
Some((text("name")?, text("xml")?))
}
/// The SVG filter effects of appearance `a` (its own and its fills' and strokes').
fn appearance_filters(a: &Appearance, out: &mut BTreeSet<(String, String)>) {
let items = a.items.iter().flat_map(|i| i.effects().iter());
out.extend(a.effects.iter().chain(items).filter_map(filter_of));
}
/// The SVG filter effects in the subtree of `n` (opacity-mask art included).
fn node_filters(n: &Node, out: &mut BTreeSet<(String, String)>) {
n.walk(&mut |m| {
appearance_filters(&m.appearance, out);
if let Some(mask) = &m.extras().mask {
node_filters(&mask.art, out);
}
});
}
/// A global or spot colour of its own (not a tint of another swatch): what colours link to.
fn is_base(w: &Swatch) -> bool {
(w.global || w.spot) && matches!(w.paint, Paint::Solid { swatch: None, .. })
@@ -527,6 +552,9 @@ impl SwatchChoices {
#[derive(Clone, Debug, Default)]
struct Relink {
renames: BTreeMap<Res, BTreeMap<String, String>>,
/// An SVG filter effect's `(name, xml)` on the clipboard → its `(name, xml)` in the document
/// ([`Clipboard::filter_links`]).
filters: BTreeMap<(String, String), (String, String)>,
/// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped.
styles: BTreeMap<u32, u32>,
/// Swatch (its name in the document) → the colour linked colours show at 100 %.
@@ -566,6 +594,15 @@ impl Relink {
});
}
fn appearance(&self, a: &mut Appearance) {
if !self.filters.is_empty() {
let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut());
for e in a.effects.iter_mut().chain(items) {
if let Some((name, xml)) = filter_of(e).and_then(|k| self.filters.get(&k)) {
e.params["name"] = json!(name);
e.params["xml"] = json!(xml);
}
}
}
for it in &mut a.items {
self.paint(it.paint_mut());
if let AppearanceItem::Stroke(l) = it
@@ -1013,6 +1050,7 @@ impl Clipboard {
/// of definitions that use each other is matched on its own ([`loop_groups`]).
fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported {
let mut out = Imported::default();
out.relink.filters = self.filter_links(d, same_doc);
self.import_images(d, used, same_doc, &mut out);
self.import_swatches(d, used, choices, same_doc, &mut out);
// Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art
@@ -1170,10 +1208,65 @@ impl Clipboard {
}
vectorcraft_brush::store(d, &lib);
}
out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty());
out.relinks = !(out.relink.renames.is_empty()
&& out.relink.filters.is_empty()
&& out.relink.styles.is_empty()
&& out.relink.colors.is_empty()
&& used.styles.is_empty());
out
}
/// How the pasted SVG filter effects relink ([`Relink::filters`]). Each names the document's
/// filter of its name; one that draws another definition than that filter takes the document's
/// filter when pasted back into the document it came from, and elsewhere keeps its own under a
/// free name, so editing or deleting the document's filter leaves it alone. When that name would
/// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else
/// a free letter or digit; the filter's id is always its name.
fn filter_links(&self, d: &Document, same_doc: bool) -> BTreeMap<(String, String), (String, String)> {
let mut found = BTreeSet::new();
for n in &self.nodes {
node_filters(n, &mut found);
}
for s in &self.symbols {
node_filters(&s.art, &mut found);
}
for a in self.patterns.iter().flat_map(|p| p.art.iter()) {
node_filters(a, &mut found);
}
for a in self.brushes.iter().flat_map(Brush::art) {
node_filters(a, &mut found);
}
for g in &self.graphic_styles {
appearance_filters(&g.appearance, &mut found);
}
let mut taken: BTreeSet<String> = d.svg_filters.iter().map(|f| f.name.clone()).chain(found.iter().map(|(n, _)| n.clone())).collect();
let mut links = BTreeMap::new();
for (name, xml) in found {
let Some(held) = d.svg_filters.iter().find(|f| f.name == name).filter(|f| f.xml != xml.trim()) else { continue };
let to = if same_doc {
(name.clone(), held.xml.clone())
} else {
// Free names made from the starts of the name, longest first, then free letters and
// digits: the first under which the filter still draws.
let mut ends: Vec<usize> = name.char_indices().map(|(i, _)| i).skip(1).collect();
ends.push(name.len());
let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty());
let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n)));
let single = ('a'..='z').chain('0'..='9').map(String::from).filter(|n| !taken.contains(n));
let renamed = shorter.chain(single).find_map(|new| {
let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new)
.filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok())?;
Some((new, drawn))
});
let Some((new, drawn)) = renamed else { continue };
taken.insert(new.clone());
(new, drawn)
};
links.insert((name, xml), to);
}
links
}
/// Image blobs: a different blob under a key the document uses gets a new key.
fn import_images(&self, d: &mut Document, used: &Used, same_doc: bool, out: &mut Imported) {
for (key, blob) in self.images.iter().filter(|(k, _)| used.has(Res::Image, k)) {
@@ -478,6 +478,32 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
assert_eq!(actual.cached_bounds, Some(expected));
}
/// Threaded type read from a file flows through its frames in the file's own composite fonts, when
/// no open document defines them.
#[test]
fn an_opened_file_flows_its_threads_in_its_own_composite_fonts() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Threaded")]})).unwrap();
let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(4);
let a = s
.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 20, "font": "CF Threaded", "area": {"width": 150, "height": 60}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 300})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
let first = text(&s, a).plain_text();
assert!(!first.is_empty() && first.len() < story.len());
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
// A new front document without the font: the font database no longer holds it.
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
assert!(vectorcraft_text::FontDb::global().composite_font("CF Threaded").is_none());
let file = crate::cmd::fileio::read_for_open("threaded.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
crate::cmd::fileio::open_read(&mut s, file).unwrap();
assert_eq!(text(&s, a).plain_text(), first);
}
/// A document replaced while in the back (File › Revert) is measured in its own composite fonts
/// when it comes to the front, not in the front document's.
#[test]
+148 -20
View File
@@ -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<EndedLink> {
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<EndedLink>
ended
}
/// The dimensions in `ended` whose art is as it was are live again.
pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec<EndedLink>) {
/// 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<EndedLink>) {
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<NodeId> {
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<NodeId>,
index: usize,
node: Arc<Node>,
assets: Vec<(usize, vectorcraft_doc::assets::ExportAsset)>,
bindings: Vec<(NodeId, std::collections::BTreeSet<String>)>,
}
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<Lifted> {
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<Lifted>) {
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<NodeId> = 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<bool> {
@@ -320,17 +458,7 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
// 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.
+171 -10
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
@@ -109,9 +109,155 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
move_effect
),
cmd!(
query "effect.deferredStatus",
"Deferred Effects",
[],
None,
"{} the live-effect plug-in stages the active document left undrawn: while a document is drawn on screen, the plug-in effects of its first view share a 2,000 ms budget (each run also keeps to its own 5,000 ms limit); a stage left out for want of time, or one that times out, fails or has no plug-in, draws its input art unchanged and stays listed until it gives a result (Effect › Refresh Deferred Effects, an edit to its input or parameters, or an export, which runs every stage under the per-call limit), or until its object is deleted or its effect is removed, moved or hidden (or its fill or stroke is hidden) → {budgetMs: 2000, effects: [{objectId (also an object in the art of a symbol, pattern, brush or opacity mask), effectIndex (in the object's effects, or in the fill's or stroke's), item? (the fill or stroke whose effect it is), pluginId, reason: budget|timeout|missing|failed|safeMode}]} (empty: none)",
has_doc,
deferred_status
),
cmd!(
"effect.refreshDeferred",
"Refresh Deferred Effects",
["Effect"],
None,
"{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
has_deferred,
refresh_deferred
),
]
}
/// The active document has deferred live-effect plug-in stages ([`deferred_status`]).
fn has_deferred(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if deferred_effects(&st.doc, &st.plugin_effects).is_empty() { Err("no plug-in effect is deferred".into()) } else { Ok(()) }
}
/// The objects the deferred stages of `fx` name, by id, each with whether it is in `d`'s layers.
/// An object that is not is looked for in the art `d` draws from elsewhere, where live-effect
/// plug-ins run too: the art of its symbols, patterns and brushes, and opacity masks.
fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap<u64, (Arc<Node>, bool)> {
let mut out = std::collections::HashMap::new();
let mut rest = Vec::new();
for object in fx.list().into_iter().map(|x| x.stage.object) {
match d.node_arc(NodeId(object)) {
Some(n) => {
out.insert(object, (n.clone(), true));
}
None => rest.push(object),
}
}
if rest.is_empty() {
return out;
}
let brushes = vectorcraft_brush::library(d);
let symbols = d.symbols.iter().map(|s| &*s.art);
let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a));
let layers = d.layers.iter().map(|a| &**a);
let mut look = |n: &Node| {
if rest.contains(&n.id.0) {
out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false));
}
};
for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) {
vectorcraft_doc::swatches::walk_with_masks(a, &mut look);
}
out
}
/// Is plug-in `plugin`'s effect at `stage`'s place in `n` (`None`: no such object), and visible,
/// on a visible fill or stroke when it is one's?
fn stage_holds(n: Option<&Node>, stage: vectorcraft_plugins::deferred::Stage, plugin: &str) -> bool {
let Some(n) = n else { return false };
let list = match stage.item {
None => Some(n.appearance.effects.as_slice()),
Some(i) => n.appearance.items.get(i).filter(|it| it.visible()).map(|it| it.effects().as_slice()),
};
list.and_then(|l| l.get(stage.effect)).is_some_and(|e| e.visible && e.id == vectorcraft_plugins::effect::effect_id(plugin))
}
/// Drops the records of `fx`'s stages whose object or plug-in effect is no longer at their place
/// in `d` ([`stage_holds`]), and gives the objects of the others ([`stage_objects`]).
fn prune(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap<u64, (Arc<Node>, bool)> {
let objects = stage_objects(d, fx);
fx.retain(|stage, plugin| stage_holds(objects.get(&stage.object).map(|(n, _)| &**n), stage, plugin));
objects
}
/// The deferred stages of `d` as `effect.deferredStatus` lists them. The records of stages whose
/// object or plug-in effect is no longer at their place in `d` are dropped first.
fn deferred_effects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> Vec<Value> {
prune(d, fx);
fx.list()
.into_iter()
.map(|x| {
let mut v = json!({"objectId": x.stage.object, "effectIndex": x.stage.effect, "pluginId": x.plugin, "reason": x.reason.name()});
if let Some(i) = x.stage.item {
v["item"] = json!(i);
}
v
})
.collect()
}
/// `effect.deferredStatus`.
fn deferred_status(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
Ok(json!({"budgetMs": vectorcraft_plugins::deferred::OPENING_BUDGET_MS, "effects": deferred_effects(&st.doc, &st.plugin_effects)}))
}
/// Draws `n` alone, small, as the canvas would (its live-effect plug-ins run inside the view that
/// applies): what matters is that it is drawn, not its pixels. Is every part of it drawn, none left
/// out for its size?
fn draw_small(renderer: &mut vectorcraft_render::Renderer, doc: &Document, n: &Arc<Node>) -> bool {
let Some(b) = n.geometric_bounds() else { return false };
let scale = 64.0 / b.width().max(b.height()).max(1.0);
let view = vectorcraft_geom::Affine::scale(scale) * vectorcraft_geom::Affine::translate((1.0 - b.x0, 1.0 - b.y0));
renderer.stats = Default::default();
renderer.render_node(doc, n, 66, 66, view);
renderer.stats.culled == 0
}
/// `effect.refreshDeferred`: each object of the document's layers is drawn again alone, small,
/// inside the document's view, so its stages are run with the input they were deferred with; a
/// hidden object, or one in a hidden layer or group, is not drawn. An object in the art of a
/// symbol, pattern, brush or opacity mask runs where that art is used: the visible layers are
/// drawn then. A stage input left out of a small drawing for its size stays deferred.
fn refresh_deferred(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone());
let ids = super::named_ids(p)?;
let objects = prune(&doc, &fx);
if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) {
return Err(EngineError::NoNode(*id));
}
let ids: Option<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect());
let counts = fx.refresh(ids.as_deref(), |targets| {
let mut renderer = vectorcraft_render::Renderer::new();
let mut whole = Vec::new();
fx.view(|| {
let mut elsewhere = false;
for id in targets {
match objects.get(id) {
Some((n, true)) if doc.is_visible(NodeId(*id)) && draw_small(&mut renderer, &doc, n) => whole.push(*id),
Some((_, false)) => elsewhere = true,
_ => {}
}
}
if elsewhere {
for layer in doc.layers.iter().filter(|l| l.visible) {
draw_small(&mut renderer, &doc, layer);
}
}
});
whole
});
Ok(json!({"refreshed": counts.refreshed, "stillDeferred": counts.still_deferred, "failed": counts.failed}))
}
fn ids_json(ids: &[NodeId]) -> Value {
json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())
}
@@ -674,11 +820,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 +842,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 +1131,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
// 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 +1142,24 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
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))?;
+35 -11
View File
@@ -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<CommandSpec> {
"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<CommandSpec> {
"{} 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<CommandSpec> {
/// (#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<Value> {
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<NodeId> = 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<Value> {
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<NodeId> = 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::<Vec<_>>() }))
+3 -1
View File
@@ -270,7 +270,9 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result<Option<Flat>> {
let pt = im.xf.determinant().abs().sqrt();
let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI };
let area = (bounds.width() * bounds.height()).max(1.0);
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt());
// No side longer than the renderer draws (rounded up to whole pixels below).
let longest = (f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0) / bounds.width().max(bounds.height()).max(1.0);
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt()).min(longest);
if !(scale.is_finite() && scale > 0.0) {
return Ok(None);
}
+5 -1
View File
@@ -506,7 +506,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
// document keeps its text as saved), and every frame of a thread takes its first frame's
// Optical Margin Alignment setting and writing direction. When that changes the direction of a
// frame of a restored document, the story flows through that frame's thread again. Threaded
// frames keep their size: Auto Size is off for them.
// frames keep their size: Auto Size is off for them. The story is laid out in the document's
// own composite fonts.
let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() };
let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts);
if !doc.text_threads.is_empty() && !restored {
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
} else if !doc.text_threads.is_empty() {
@@ -517,6 +520,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
// paragraph's first-line indent and list marker.
crate::cmd::threads::mark_continued(&mut doc);
}
drop(lent);
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
+3
View File
@@ -484,6 +484,9 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool
fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option<PathData>, o: &FlattenOptions) -> Result<Raster> {
let mut scale = ppi / 72.0;
scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt());
// No side longer than the renderer draws: the image grows by a pixel on each side and is
// rounded up to whole pixels below.
scale = scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0));
let px = 1.0 / scale;
let rect = rect.inflate(px, px);
let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c));
+1 -1
View File
@@ -10,7 +10,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Flatten Transparency…",
["Object"],
None,
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach; an image larger than 64 Mpx or 32,768 px a side is made at a lower resolution), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
has_doc,
flatten
),
+2 -2
View File
@@ -43,8 +43,8 @@ use generate::generate;
#[cfg(test)]
use series::default_series_color;
pub(crate) use series::{
capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets,
series_object_opacity_differs, shown_paint,
capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity,
capture_series_paints, capture_series_top_paints, paint_targets, series_object_opacity_differs, shown_paint,
};
#[cfg(test)]
+12 -8
View File
@@ -2,7 +2,8 @@ use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, rada
use super::columns::{Allowance, ColumnArt, column_art, column_designs};
use super::data::fmt_value;
use super::labels::{
Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of,
Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_extra_height, label_shift, label_size, label_width, polyline,
recolour_text, runs, shadow_of,
};
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
use super::series::{series_marks, with_series_opacity};
@@ -901,18 +902,21 @@ impl<'a> Plot<'a> {
fn legend_label(&mut self, (x, y): (f64, f64), label: &str) -> Arc<Node> {
let ls = self.ls;
let (dx, dy) = label_shift(&self.g.label_styles, label, self.b.d.setup.system_layout);
let (x, y) = (x + dx, y + dy);
self.b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false)
}
/// Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row
/// just above it.
fn legend_across_top(&mut self) -> Vec<Arc<Node>> {
let (g, r, ls) = (self.g, self.r, self.ls);
let (g, r, ls, system_layout) = (self.g, self.r, self.ls, self.b.d.setup.system_layout);
let entries: Vec<(usize, String)> = (0..self.legend_series()).map(|s| (s, self.legend_text(s))).filter(|(_, l)| !blank_label(l)).collect();
let mut rows: Vec<Vec<(usize, String, f64)>> = vec![vec![]];
let mut rows: Vec<Vec<(usize, String, f64, f64)>> = vec![vec![]];
let mut used = 0.0;
for (s, label) in entries {
let w = 12.0 + label_width(&g.label_styles, &label) + 12.0;
let w = 12.0 + label_width(&g.label_styles, &label, system_layout) + 12.0;
let h = ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&g.label_styles, &label, system_layout);
// The gap after an entry may run past the plot's edge.
if used + w - 12.0 > r.width() && rows.last().is_some_and(|row| !row.is_empty()) {
rows.push(vec![]);
@@ -920,10 +924,10 @@ impl<'a> Plot<'a> {
}
used += w;
if let Some(row) = rows.last_mut() {
row.push((s, label, w));
row.push((s, label, w, h));
}
}
let height = |row: &[(usize, String, f64)]| ls * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64;
let height = |row: &[(usize, String, f64, f64)]| row.iter().map(|(.., h)| *h).fold(ls * 1.8, f64::max);
// Above the plot and anything drawn over it (radar and stacked-pie labels).
let top = self.b.out.iter().chain(self.on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min);
let top = self.bars_reach.map_or(top, |b| top.min(b.y0));
@@ -931,7 +935,7 @@ impl<'a> Plot<'a> {
let mut labels = vec![];
for row in &rows {
let mut x = r.x0;
for (s, label, w) in row {
for (s, label, w, _) in row {
self.legend_swatch(*s, (x, y));
labels.push(self.legend_label((x, y), label));
x += w;
@@ -954,7 +958,7 @@ impl<'a> Plot<'a> {
y += ls * 1.8;
continue;
}
y += ls * 1.8 * label.split('|').count() as f64;
y += ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&self.g.label_styles, &label, self.b.d.setup.system_layout);
self.legend_swatch(s, (x, row_y));
labels.push(self.legend_label((x, row_y), &label));
}
+90 -7
View File
@@ -10,7 +10,7 @@ use super::{
pub(super) struct Gen<'a> {
pub(super) d: &'a mut Document,
pub(super) out: Vec<Arc<Node>>,
/// The labels' kept character styles.
/// The labels' kept character styles and paragraph attributes.
pub(super) styles: &'a vectorcraft_doc::LabelStyles,
/// What the column designs may still add.
pub(super) columns: Allowance,
@@ -45,6 +45,34 @@ pub(super) fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) ->
st
}
/// The paragraph attributes labels of `role` are set with: the kept ones, else the defaults. The caller sets the
/// alignment.
pub(super) fn label_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::ParaStyle {
let mut para = kept_para(styles, role).cloned().unwrap_or_default();
// A file's attributes within the limits the Paragraph controls keep to (they are copied onto every label).
para.keep_spacing_in_range();
para
}
/// The paragraph attributes labels of `role` keep, if any.
fn kept_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::ParaStyle> {
match role {
Role::Value => styles.values_para.as_ref(),
Role::Category => styles.categories_para.as_ref(),
Role::Legend => styles.legend_para.as_ref(),
}
}
/// The anti-aliasing method labels of `role` are drawn with: the kept one, else Sharp.
pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::text::TypeAntiAlias {
let kept = match role {
Role::Value => styles.values_anti_alias,
Role::Category => styles.categories_anti_alias,
Role::Legend => styles.legend_anti_alias,
};
kept.unwrap_or_default()
}
/// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it.
pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 {
label_style(styles, role).size
@@ -61,19 +89,39 @@ impl Gen<'_> {
self.path(PathData::from_bezpath(&bp), Paint::None, Paint::solid(Color::BLACK), 0.5)
}
pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc<Node> {
let t = self.text_object(role, at, s, justify);
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
}
/// Label `s` of `role` set at `at` and laid out, not yet in the document.
fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject {
let style = label_style(self.styles, role);
let mut t = TextObject::point(at, s, style);
t.para = label_para(self.styles, role);
t.anti_alias = label_anti_alias(self.styles, role);
t.para.justify = justify;
t.system_layout = self.d.setup.system_layout;
refresh_bounds(&mut t);
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
t
}
/// A category or series label: a `|` in it starts a new line (Add graph labels). `middle`: the lines are centred
/// on `at` (labels beside an axis or in a wedge) instead of running down from it.
pub(super) fn label(&mut self, role: Role, at: Point, s: &str, justify: Justify, middle: bool) -> Arc<Node> {
let lines = s.split('|').count();
let at = if middle { Point::new(at.x, at.y - (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0) } else { at };
self.text(role, at, &s.replace('|', "\n"), justify)
// Default leading puts the first and last baselines this far above and below `at`.
let half = (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0;
let start = if middle { Point::new(at.x, at.y - half) } else { at };
let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify);
// Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An
// empty line has no baseline: an empty first or last line leaves the label where the default leading puts it.
if middle && kept_para(self.styles, role).is_some() {
let ys: Vec<f64> = t.baselines().map(|(a, _)| a.y).collect();
let ends_set = s.split('|').next().is_some_and(|l| !l.is_empty()) && s.split('|').next_back().is_some_and(|l| !l.is_empty());
let set = s.split('|').filter(|l| !l.is_empty()).count();
if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ends_set && ys.len() == set) {
t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf;
}
}
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
}
pub(super) fn group(&mut self, name: &str, children: Vec<Arc<Node>>) -> Arc<Node> {
let mut g = Node::group(self.d.alloc_id(), children);
@@ -162,12 +210,47 @@ pub(super) fn shadow_of(d: &mut Document, n: &Node) -> Arc<Node> {
Arc::new(d.reid(&c))
}
/// How wide label `s` is set (its widest line), without adding it to a document.
pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 {
/// The bounds of legend label `s` set from the origin with paragraph attributes `para` and the document's System
/// Layout setting `system_layout`, as [`Gen::text`] sets it, without adding it to a document.
fn legend_label_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, para: vectorcraft_doc::ParaStyle, system_layout: bool) -> Option<Rect> {
let style = label_style(styles, Role::Legend);
let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style);
t.para = para;
t.para.justify = Justify::Left;
t.system_layout = system_layout;
refresh_bounds(&mut t);
Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds().map_or(0.0, |b| b.width())
Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds()
}
/// How far label `s` reaches right of where it starts (its widest line and any indent), after the legend moves it right
/// by [`label_shift`], without adding it to a document.
pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 {
legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout)
.map_or(0.0, |b| (b.x1 + label_shift(styles, s, system_layout).0).max(0.0))
}
/// The bounds of legend label `s` set with its kept paragraph attributes and with the defaults, when it has kept ones.
fn kept_and_default_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> Option<(Rect, Rect)> {
styles.legend_para.as_ref()?;
Some((
legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout)?,
legend_label_bounds(styles, s, vectorcraft_doc::ParaStyle::default(), system_layout)?,
))
}
/// How much further left and further up legend label `s` reaches with its kept paragraph attributes than with the
/// defaults (a negative indent or negative spacing), so the legend moves it right and down by that much and it stays
/// clear of its swatch and of the entries before it.
pub(super) fn label_shift(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> (f64, f64) {
kept_and_default_bounds(styles, s, system_layout)
.map_or((0.0, 0.0), |(kept, default)| ((default.x0 - kept.x0).max(0.0), (default.y0 - kept.y0).max(0.0)))
}
/// How much further down legend label `s` reaches with its kept paragraph attributes than with the defaults (more
/// leading, or space before or after), after the legend moves it down by [`label_shift`], so the legend makes room
/// for it.
pub(super) fn label_extra_height(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 {
kept_and_default_bounds(styles, s, system_layout).map_or(0.0, |(kept, default)| (kept.y1 + (default.y0 - kept.y0).max(0.0) - default.y1).max(0.0))
}
/// A label with nothing to show (empty, or only `|` line breaks).
+59 -12
View File
@@ -1,7 +1,7 @@
use vectorcraft_color::BlendMode;
use super::axis::radar_axis;
use super::labels::{Role, filled_lines_on, label_style};
use super::labels::{Role, filled_lines_on, label_para, label_style};
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
// ---------- generation ----------
@@ -406,23 +406,34 @@ fn graph_labels(doc: &Document) -> HashMap<NodeId, (NodeId, Role)> {
out
}
/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or
/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so
/// they undo together. Returns whether a kept style changed.
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
if ids.is_empty() {
return false;
}
/// Keep the anti-aliasing method `method` given to graph labels `ids` for every label of their kind, as
/// [`capture_label_styles`] keeps a formatting change. Returns whether a kept method changed.
pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], method: vectorcraft_doc::text::TypeAntiAlias) -> bool {
let labels = graph_labels(doc);
if labels.is_empty() {
return false;
}
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
kinds.dedup();
let mut changed = false;
for (graph, role) in kinds {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let slot = match role {
Role::Value => &mut spec.label_styles.values_anti_alias,
Role::Category => &mut spec.label_styles.categories_anti_alias,
Role::Legend => &mut spec.label_styles.legend_anti_alias,
};
changed |= *slot != Some(method);
*slot = Some(method);
}
changed
}
/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or
/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so
/// they undo together. Returns whether a kept style changed.
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
let mut changed = false;
for (graph, role) in label_kinds(doc, ids) {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let mut style = label_style(&spec.label_styles, role);
change(&mut style);
@@ -437,6 +448,42 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &
changed
}
/// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character
/// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes.
pub(crate) fn capture_label_paragraphs(
doc: &mut Document,
ids: &[NodeId],
change: &dyn Fn(&mut vectorcraft_doc::ParaStyle) -> Result<()>,
) -> Result<bool> {
let mut changed = false;
for (graph, role) in label_kinds(doc, ids) {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let mut para = label_para(&spec.label_styles, role);
change(&mut para)?;
let slot = match role {
Role::Value => &mut spec.label_styles.values_para,
Role::Category => &mut spec.label_styles.categories_para,
Role::Legend => &mut spec.label_styles.legend_para,
};
changed |= slot.as_ref() != Some(&para);
*slot = Some(para);
}
Ok(changed)
}
/// The graphs and kinds of label among `ids`, each once.
fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> {
if ids.is_empty() {
return vec![];
}
let labels = graph_labels(doc);
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
kinds.dedup();
kinds
}
/// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a
/// selection made only of this graph's series (Group Selection). Empty: the whole graph.
pub(super) fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result<Vec<usize>> {
+279
View File
@@ -4055,6 +4055,45 @@ fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() {
assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default());
}
/// An anti-aliasing method given to one category label is kept for every category label when the graph is drawn
/// again, through save and open, and undoes with the change.
#[test]
fn a_graph_labels_anti_aliasing_method_survives_it_being_drawn_again() {
use vectorcraft_doc::text::TypeAntiAlias;
let methods = |s: &Session, id: NodeId| -> Vec<(String, TypeAntiAlias)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut out = vec![];
for g in ["Axes", "Legend"] {
for c in group(&n, g).children().unwrap() {
if let NodeKind::Text(t) = &c.kind {
out.push((t.plain_text(), t.anti_alias));
}
}
}
out
};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let c = text_id(&s, id, "Axes", "Q2");
s.execute("text.setFormat", &json!({"ids": [c.0], "antiAlias": "strong"})).unwrap();
label_looks(&mut s, id);
let drawn = methods(&s, id);
let method = |t: &str| drawn.iter().find(|(x, _)| x == t).map(|(_, m)| *m).unwrap();
assert_eq!((method("Q1"), method("Q3")), (TypeAntiAlias::Strong, TypeAntiAlias::Strong), "every category label: {drawn:?}");
assert_eq!((method("0"), method("2024")), (TypeAntiAlias::Sharp, TypeAntiAlias::Sharp), "other labels: {drawn:?}");
// One undo takes the data change back, the next the method and the kept method together.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, None);
s.execute("edit.redo", &json!({})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-label-anti-alias").join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, Some(TypeAntiAlias::Strong));
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none());
}
/// A stroke given to all of a graph's text without a weight is kept at 1 pt, as `paint.setStroke` keeps it.
#[test]
fn a_stroke_given_to_the_whole_graphs_text_is_kept_at_1_pt() {
@@ -4219,3 +4258,243 @@ fn csv_round_trip_keeps_blank_row_in_single_column_graph() {
assert!(c.is_empty());
assert_eq!(r, g.cells());
}
/// Each generated label's text, paragraph attributes and alignment.
fn label_paras(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_doc::ParaStyle)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut out = vec![];
for name in ["Axes", "Legend"] {
for c in group(&n, name).children().unwrap() {
if let NodeKind::Text(t) = &c.kind {
out.push((t.plain_text(), t.para.clone()));
}
}
}
out
}
#[test]
fn paragraph_formats_are_kept_when_the_graph_is_drawn_again() {
use vectorcraft_doc::{Composer, ParaDirection, text::Kashida};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let aligned: Vec<_> = label_paras(&s, id).into_iter().map(|(t, p)| (t, p.justify)).collect();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setFormat", &json!({"composer": "singleLine", "kashida": "long", "direction": "rightToLeft", "wordSpacing": [70, 90, 120]}))
.unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let paras = label_paras(&s, id);
assert!(!paras.is_empty());
for (text, p) in &paras {
assert_eq!(p.composer, Composer::SingleLine, "{text}");
assert_eq!(p.kashida, Kashida::Long, "{text}");
assert_eq!(p.direction, Some(ParaDirection::RightToLeft), "{text}");
assert_eq!(p.justification.word_spacing, [70.0, 90.0, 120.0], "{text}");
}
// The graph still aligns each label to its place.
assert_eq!(paras.iter().map(|(t, p)| (t.clone(), p.justify)).collect::<Vec<_>>(), aligned);
// One kind of label: only that kind keeps it, also through save and open.
let col = graph(&mut s, "column");
let v = text_id(&s, col, "Axes", "4");
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
s.execute("text.setFormat", &json!({"composer": "singleLine"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-label-paras").join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
s.execute("select.set", &json!({"ids": [col.0]})).unwrap();
s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let paras = label_paras(&s, col);
let composer = |t: &str| paras.iter().find(|(x, _)| x == t).unwrap().1.composer;
assert_eq!(composer("0"), Composer::SingleLine, "every value axis number");
assert_eq!(composer("Q1"), Composer::default(), "category labels as they were");
assert_eq!(composer("2024"), Composer::default(), "legend labels as they were");
// Part of a label's text: nothing kept.
let q = text_id(&s, col, "Axes", "Q2");
s.execute("select.set", &json!({"ids": [q.0]})).unwrap();
s.execute("text.setFormat", &json!({"kashida": "short", "start": 0, "end": 1})).unwrap();
s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
assert!(label_paras(&s, col).iter().all(|(_, p)| p.kashida == Kashida::default()));
// Spacing that would be out of order in the kept attributes is refused, even when it is in order on the label.
let v = text_id(&s, col, "Axes", "4");
s.execute("select.set", &json!({"ids": [col.0]})).unwrap();
s.execute("text.setFormat", &json!({"wordSpacing": [60, 70, 80]})).unwrap();
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
s.execute("text.setFormat", &json!({"wordSpacing": [80, 100, 133], "start": 0, "end": 1})).unwrap();
assert!(s.execute("text.setFormat", &json!({"wordSpacing": {"min": 90}})).is_err());
let kept = spec(&s, col).label_styles.values_para.unwrap();
assert_eq!(kept.justification.word_spacing, [60.0, 70.0, 80.0]);
}
#[test]
fn a_legend_across_top_spaces_its_labels_by_their_kept_paragraph_settings() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
let l = text_id(&s, id, "Legend", "2024");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"leftIndent": 40})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "2024")).unwrap();
let label = label.geometric_bounds().unwrap();
let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap();
assert!(next.x0 >= label.x1, "the next swatch starts after the indented label: {label:?} {next:?}");
}
#[test]
fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_again() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",2024,2025\nQ|1,3,2\nQ2,5,4\nQ3,-1,6";
let id = NodeId(
s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let middle = |s: &Session| {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let label = group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n1")).unwrap();
let b = label.geometric_bounds().unwrap();
(b.y0 + b.y1) / 2.0
};
let before = middle(&s);
let l = text_id(&s, id, "Axes", "Q\n1");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"autoLeading": 400, "spaceBefore": 6})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let after = middle(&s);
assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}");
}
#[test]
fn a_centered_label_with_an_empty_middle_line_and_kept_leading_stays_centered() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",2024,2025\nQ||1,3,2\nQ2,5,4\nQ3,-1,6";
let id = NodeId(
s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let middle = |s: &Session| {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let label =
group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n\n1")).unwrap();
let b = label.geometric_bounds().unwrap();
(b.y0 + b.y1) / 2.0
};
let before = middle(&s);
let l = text_id(&s, id, "Axes", "Q\n\n1");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let after = middle(&s);
assert!((after - before).abs() < 0.01, "the three-line category label keeps its middle: {before} {after}");
}
#[test]
fn a_legend_across_top_measures_its_labels_with_system_layout() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
s.execute("type.systemLayout", &json!({"on": true})).unwrap();
// Each `i` at 2 pt advances a whole point with System Layout, far more than its own width.
let name = "i".repeat(60);
let csv = format!(",{name},2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6");
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
let l = text_id(&s, id, "Legend", &name);
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 2})).unwrap();
s.execute("text.setFormat", &json!({"leftIndent": 1})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == name)).unwrap();
let label = label.geometric_bounds().unwrap();
let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap();
assert!(next.x0 >= label.x1, "the next swatch starts after the label set with System Layout: {label:?} {next:?}");
}
#[test]
fn a_legend_label_with_a_kept_negative_indent_stays_clear_of_its_swatch() {
for across_top in [true, false] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"legendAcrossTop": across_top})).unwrap();
let l = text_id(&s, id, "Legend", "2025");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"leftIndent": -40})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bounds = |text: &str| {
let label =
group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
label.geometric_bounds().unwrap()
};
let (first, label) = (bounds("2024"), bounds("2025"));
let swatch = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap();
assert!(label.x0 >= swatch.x1, "the label starts after its swatch (across top: {across_top}): {swatch:?} {label:?}");
if across_top {
assert!(swatch.x0 >= first.x1, "the swatch starts after the label before it: {first:?} {swatch:?}");
}
}
}
#[test]
fn a_legend_makes_room_for_the_kept_leading_of_its_labels() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",20|24,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let l = text_id(&s, id, "Legend", "20\n24");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bounds = |text: &str| {
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
label.geometric_bounds().unwrap()
};
let (first, next) = (bounds("20\n24"), bounds("2025"));
assert!(next.y0 >= first.y1, "the next legend label starts below the first one: {first:?} {next:?}");
}
#[test]
fn a_legend_label_with_kept_negative_space_before_stays_below_the_entry_above_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",2024,20|25\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let l = text_id(&s, id, "Legend", "20\n25");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"spaceBefore": -60})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bounds = |text: &str| {
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
label.geometric_bounds().unwrap()
};
let (first, next) = (bounds("2024"), bounds("20\n25"));
assert!(next.y0 >= first.y1, "the second legend label starts below the first one: {first:?} {next:?}");
}
+4 -2
View File
@@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rasterize…",
["Object"],
None,
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
has_selection,
rasterize
),
@@ -670,7 +670,9 @@ fn render_pixels(
opts: &vectorcraft_render::RenderOptions,
color: RasterColorModel,
) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> {
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
// No side longer than the renderer draws: it would cut the image off there.
let side = f64::from(vectorcraft_render::MAX_RASTER_SIDE);
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > side || region.height() * scale > side {
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
}
let mut r = vectorcraft_render::Renderer::new();
File diff suppressed because one or more lines are too long
+10 -6
View File
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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<Value> {
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());
@@ -152,6 +152,7 @@ pub(super) const ALLOWED: &[&str] = &[
"edit.findNext",
"edit.findReplace",
"effect.apply",
"effect.deferredStatus",
"effect.duplicate",
"effect.expandAppearance",
"effect.list",
@@ -662,11 +663,13 @@ pub(super) const ALLOWED: &[&str] = &[
];
/// Commands a plug-in may not call although no rule refuses them, in order: they change the
/// preferences, a tool's options, a drag's state or the font names the whole app knows.
/// preferences, a tool's options, a drag's state or the font names the whole app knows, or run
/// plug-ins again.
pub(super) const DENIED: &[&str] = &[
"addon.cleanup.splitText",
"addon.pathtools.movePointsTogether",
"addon.pen.nextSegment",
"effect.refreshDeferred",
"graphicStyle.setOptions",
"object.blend.options",
"object.liquify",
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Setup",
[],
None,
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
has_doc,
setup
),
+4 -2
View File
@@ -76,9 +76,11 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings, rich_black: b
let mut images = vec![];
for (_, r) in regions {
let area = outset(r, bleed);
// At most MAX_PIXELS, and 65535 pixels a side.
// At most MAX_PIXELS, and no side longer than the renderer draws (rounded up to whole
// pixels below).
let side = area.width().max(area.height()).max(1.0);
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(65_535.0 / side);
let longest = f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0;
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(longest / side);
if !(scale.is_finite() && scale > 0.0) {
continue;
}
File diff suppressed because one or more lines are too long
+63
View File
@@ -158,6 +158,7 @@ pub const BUILTIN: &[BuiltinSymbolLibrary] = &[
BuiltinSymbolLibrary { id: "basic-shapes", name: "Basic Shapes", make: basic_shapes },
BuiltinSymbolLibrary { id: "ui-frames", name: "UI Frames", make: ui_frames },
BuiltinSymbolLibrary { id: "map-markers", name: "Map Markers", make: map_markers },
BuiltinSymbolLibrary { id: "inventory-icons", name: "Inventory Icons", make: inventory_icons },
];
/// Built-in library `id`, made once.
@@ -249,6 +250,68 @@ fn map_markers() -> SymbolLibrary {
SymbolLibrary { name: "Map Markers".into(), doc }
}
/// Inventory Icons: sword, shield, potion, key and gem for loot/RPG art (original, no 9-slice).
fn inventory_icons() -> SymbolLibrary {
let c = Point::ZERO;
let sword = [
(0.0, -22.0),
(3.0, -10.0),
(3.0, 6.0),
(8.0, 8.0),
(8.0, 12.0),
(3.0, 12.0),
(3.0, 20.0),
(-3.0, 20.0),
(-3.0, 12.0),
(-8.0, 12.0),
(-8.0, 8.0),
(-3.0, 6.0),
(-3.0, -10.0),
];
let shield = [(0.0, -20.0), (16.0, -12.0), (14.0, 8.0), (0.0, 20.0), (-14.0, 8.0), (-16.0, -12.0)];
let potion =
[(-4.0, -20.0), (4.0, -20.0), (4.0, -12.0), (10.0, -6.0), (12.0, 6.0), (8.0, 18.0), (-8.0, 18.0), (-12.0, 6.0), (-10.0, -6.0), (-4.0, -12.0)];
let key = [
(-4.0, -18.0),
(4.0, -18.0),
(6.0, -14.0),
(6.0, -8.0),
(4.0, -6.0),
(4.0, 16.0),
(8.0, 16.0),
(8.0, 20.0),
(-2.0, 20.0),
(-2.0, 12.0),
(0.0, 12.0),
(0.0, -6.0),
(-4.0, -8.0),
(-6.0, -14.0),
];
let sword: Vec<Point> = sword.iter().map(|&(x, y)| Point::new(x, y)).collect();
let shield: Vec<Point> = shield.iter().map(|&(x, y)| Point::new(x, y)).collect();
let potion: Vec<Point> = potion.iter().map(|&(x, y)| Point::new(x, y)).collect();
let key: Vec<Point> = key.iter().map(|&(x, y)| Point::new(x, y)).collect();
let icons = [
("Sword", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&sword, true)), "#95a5a6"),
("Shield", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&shield, true)), "#3498db"),
("Potion", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&potion, true)), "#9b59b6"),
("Key", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&key, true)), "#f1c40f"),
("Gem", shapes::polygon(c, 16.0, 6, 0.0), "#1abc9c"),
];
let mut doc = Document::new(100.0, 100.0);
for (name, path, hex) in icons {
let fill = Color::from_hex(hex).unwrap_or(Color::BLACK);
let edge = fill.lerp(&Color::BLACK, 0.35);
let id = doc.alloc_id();
let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25));
let Ok((art, size, _)) = normalize(art) else { continue };
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
set_natural_size(&mut doc, name, Some(size));
}
doc.title = "Inventory Icons".into();
SymbolLibrary { name: "Inventory Icons".into(), doc }
}
/// An instance of symbol `name` (what the resource gathering follows).
fn instance(name: &str) -> Node {
Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })
+23 -7
View File
@@ -1280,19 +1280,25 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
// out (overflowing text) is shaped alone in its writing direction, with the rest of its grapheme
// in the grapheme's own styles.
let plain = t.plain_text();
let unchosen = holder.as_ref().filter(|_| !styled).and_then(|_| {
let mut probe = t.clone();
edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe);
own_glyph(&plain, &lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face).0)
});
let mut probe = t.clone();
edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None);
let probe_lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe);
let unchosen = holder
.as_ref()
.filter(|_| !styled)
.and_then(|_| own_glyph(&plain, &probe_lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face).0));
let substituted = unchosen.is_some_and(|g| g != default);
let choice = match gid.filter(|g| *g != default || substituted) {
None => None,
Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)),
Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))),
};
let drawn = choice.as_ref().map_or(unchosen.unwrap_or(default), |c| c.gid);
// Without a choice, the glyph drawn in whatever face draws the character, as
// `text.glyphAlternates` reports it (a fallback face's glyph, the capital of All Caps).
let drawn = match &choice {
Some(c) => c.gid,
None => drawn_glyph(&plain, &probe_lay, ch, &bytes, &face).or_else(|| shaped_alone(&probe, ch, &bytes, &face).1).unwrap_or(default),
};
// The run holding the character. The request changes nothing when the choice is the one the
// character's run holds and that run holds the character alone; otherwise the edit splits the
// run, so that the following text does not take the character into a ligature.
@@ -1315,6 +1321,16 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if held == choice && (choice.is_none() || alone) {
// The text is left as it is, so the glyph is the one the unchanged text draws. The probe
// splits a run that holds an earlier character's choice, and the split can break a ligature
// that takes in this character.
let drawn = match &choice {
None if probe.runs != t.runs => {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
drawn_glyph(&plain, &lay, ch, &bytes, &face).or_else(|| shaped_alone(&t, ch, &bytes, &face).1).unwrap_or(default)
}
_ => drawn,
};
return Ok(json!({"id": id.0, "start": bytes.start, "end": bytes.end, "gid": drawn}));
}
s.edit("Glyphs", |d, _| {
+4 -1
View File
@@ -596,6 +596,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
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<NodeId> = 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<Value> {
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);
+4
View File
@@ -110,6 +110,9 @@ pub struct DocState {
/// paths the document links to are looked at within them later too, when the Links panel
/// asks about a link outside any caller's scope.
pub opened_by: Option<Arc<file_access::AutomationRoots>>,
/// The live-effect plug-in stages deferred while the document is drawn on screen, and the time
/// budget of its first view ([`vectorcraft_plugins::deferred`]). Not saved.
pub plugin_effects: vectorcraft_plugins::deferred::Deferrals,
/// What [`DocState::any_empty_artboards`] last measured. Not saved.
empty_artboards: std::cell::RefCell<EmptyArtboards>,
/// What [`DocState::any_art_to_fit`] last measured. Not saved.
@@ -326,6 +329,7 @@ impl DocState {
last_perspective: None,
opened_by: file_access::current(),
empty_artboards: Default::default(),
plugin_effects: Default::default(),
art_to_fit: Default::default(),
}
}
+1 -1
View File
@@ -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}',
+364
View File
@@ -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<String>,
/// `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<String>),
}
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<Vec<DialogAnswer>, 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<DialogAnswer, &'static str> {
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<Cell<bool>>,
answers: RefCell<VecDeque<DialogAnswer>>,
waiting: RefCell<Option<ScriptDialog>>,
/// The error that ended the run when an answer could not reach the script.
failed: RefCell<Option<RunError>>,
records: RefCell<Vec<Value>>,
record_bytes: Cell<usize>,
count: Cell<u64>,
truncated: Cell<bool>,
}
impl Dialogs {
pub(crate) fn new(framed: bool, realm: &Realm, answers: Vec<DialogAnswer>) -> 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<ScriptDialog> {
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<RunError> {
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<String, Value>) {
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<Host>) -> 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<String> {
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<Data, HostError> {
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")))
}
}
}
+23 -12
View File
@@ -1,8 +1,11 @@
//! A script's preprocessor lines, read before it runs, as the public guide lists them
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/>):
//! `#include` and `//@include` put the text of another file in place of the line, `#includepath`
//! sets the folders `#include` looks in, `#script` names the run, and `#target` and `#strict off`
//! are accepted. The same lines are read in a file `$.evalFile` runs.
//! sets the folders `#include` looks in, `#script` names the run, `#strict on` turns on the checks
//! of `$.strict` (<https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/>:
//! a write to a read-only property is a runtime error, and objects that take no new properties
//! refuse them), and `#target` and `#strict off` are accepted. The same lines are read in a file
//! `$.evalFile` runs.
//!
//! VectorCraft's own rules:
//!
@@ -13,9 +16,15 @@
//! the `JSINCLUDE` environment variable. A later `#includepath` replaces the list.
//! - Files are read through the script's [`ScriptScope`]: a refused path ends the run, except a
//! `JSINCLUDE` folder outside the scope, which is passed over.
//! - `#target` takes any value and changes nothing. `#targetengine`, `#engine` and `#strict on` are
//! refused as unsupported, and any other `#name` as unknown; `//@name` with another name stays a
//! comment.
//! - `#target` takes any value and changes nothing. `#targetengine` and `#engine` are refused as
//! unsupported, and any other `#name` as unknown; `//@name` with another name stays a comment.
//! - The last `#strict on` or `#strict off` of the script and the files it includes sets the checks
//! for the whole run; without one they are off. A write to a property that has only a getter is
//! checked as a read-only one, the objects that take no new properties are those that are not
//! extensible (`Object.preventExtensions`, `Object.seal`, `Object.freeze`), and the rest of ES5
//! strict mode is left to `'use strict'`. Code that `eval` and `Function` compile takes the
//! checks of the code that compiles it; a file `$.evalFile` runs takes its own `#strict` line if
//! it has one, and otherwise the caller's.
//! - A run reads at most [`MAX_DEPTH`] levels of nested files, [`MAX_FILES`] files,
//! [`MAX_TOTAL_BYTES`] of source in all (the script's own included) and 8 MiB a file, and looks up
//! at most [`MAX_PROBES`] paths. A file may be included again once its earlier inclusion has
@@ -97,12 +106,13 @@ pub(crate) struct Failure {
pub column: u32,
}
/// A source with its preprocessor lines read: the text to run, where its lines came from, and the
/// name its `#script` line gave.
/// A source with its preprocessor lines read: the text to run, where its lines came from, the
/// name its `#script` line gave, and what its last `#strict` line set (`None` without one).
pub(crate) struct Expanded {
pub source: String,
pub map: SourceMap,
pub name: Option<String>,
pub strict: Option<bool>,
}
/// A file being run: its name for people and the folder its relative paths start from (none for a
@@ -130,13 +140,14 @@ pub(crate) struct Files {
}
/// The output being built: its UTF-16 text, the line it is on, the units since that line began,
/// and whether its last unit is a carriage return.
/// whether its last unit is a carriage return, and what the last `#strict` line so far set.
struct Out {
units: Vec<u16>,
line: u32,
column: u32,
after_cr: bool,
map: SourceMap,
strict: Option<bool>,
}
impl Out {
@@ -330,7 +341,7 @@ impl Files {
/// `text`, the file `shown` in folder `dir`, with its preprocessor lines read. With `script`,
/// its `#script` line names the run.
fn expand(&mut self, shown: Arc<str>, dir: Option<PathBuf>, text: &str, script: bool) -> Result<Expanded, Failure> {
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default() };
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default(), strict: None };
let mut name = None;
self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?;
let source = String::from_utf16(&out.units).map_err(|_| Failure {
@@ -341,7 +352,7 @@ impl Files {
line: 0,
column: 0,
})?;
Ok(Expanded { source, map: out.map, name })
Ok(Expanded { source, map: out.map, name, strict: out.strict })
}
/// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place
@@ -414,8 +425,8 @@ impl Files {
}
}
Some(DirectiveName::Strict) => match value.to_ascii_lowercase().as_str() {
"off" => {}
"on" => return Err(at(FailKind::Syntax, format!("unsupported strict checking: {label} on"))),
"off" => out.strict = Some(false),
"on" => out.strict = Some(true),
_ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))),
},
Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))),
+63 -7
View File
@@ -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<String, Value>,
pub fuel: u64,
/// The answers to the dialogs of a run with nobody to ask, in order.
pub answers: Vec<DialogAnswer>,
}
thread_local! {
@@ -480,6 +488,8 @@ struct Host {
commands: RefCell<HashMap<&'static str, (u64, u128, u128)>>,
/// The time the run's slices took, in nanoseconds.
run_nanos: Cell<u128>,
/// `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<include::Files>,
/// Where the lines of the script's expanded source came from.
@@ -489,6 +499,8 @@ struct Host {
/// The last error `$.evalFile` raised for a file it could not run, with the file, line and
/// column that error is at.
eval_failure: RefCell<Option<EvalFailure>>,
/// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one).
eval_strict: Cell<Option<bool>>,
}
/// An error `$.evalFile` raised, as thrown, and the file, line and column it is at.
@@ -947,7 +959,12 @@ fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
let eval_file = {
let h = host.clone();
let end = host.clone();
HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) }
let strict = host.clone();
HostItem::Evaluator {
source: function(move |a| eval_file(&h, a)),
end: Rc::new(move || end.files.borrow_mut().end_eval()),
strict_writes: Rc::new(move || strict.eval_strict.get()),
}
};
vec![
("write".into(), writer(false)),
@@ -996,6 +1013,7 @@ fn eval_file(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostErro
return Err(HostError::Syntax(message));
}
host.eval_maps.borrow_mut().push(expanded.map);
host.eval_strict.set(expanded.strict);
Ok(Data::String(expanded.source))
}
@@ -1061,7 +1079,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 +1181,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<ScriptDialog> {
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<String> {
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.name.clone()))
@@ -1270,10 +1305,12 @@ 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(),
eval_failure: RefCell::default(),
eval_strict: Cell::new(None),
});
let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0))
.map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?;
@@ -1283,9 +1320,11 @@ impl Session {
Err(f) => Err(preprocessor_error(f)),
Ok(expanded) => {
*host.script_map.borrow_mut() = expanded.map;
realm.set_strict_writes(expanded.strict == Some(true));
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 +1351,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 +1381,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 +1396,7 @@ impl Session {
}
fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> {
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 +1493,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 +1559,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 +1594,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);
}
}
+1
View File
@@ -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;
+25
View File
@@ -350,6 +350,31 @@ fn gradients_rasterize_and_flat_areas_stay_vector() {
}
}
#[test]
fn a_long_rasterized_area_is_placed_whole() {
let _settings = settings_lock();
let mut s = session();
// 5569 pt long: more than the renderer's 32,768 pixels a side at the 64 Mpx limit's scale.
let g = rect(&mut s, 10.0, 10.0, 100.0, 5569.0, "#000000");
run(
&mut s,
"paint.setFill",
json!({"ids": [g], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}),
);
run(&mut s, "transparency.set", json!({"ids": [g], "opacity": 40}));
let (ids, _, rasterized) = flatten(&mut s, json!({"ids": [g], "lineArtPpi": 1200, "gradientPpi": 1200}));
assert_eq!(rasterized, 1);
let mut placed = vec![];
node(&s, ids[0]).walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind {
placed.push((im.height, im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)))));
}
});
let [(height, b)] = placed[..] else { panic!("one image: {placed:?}") };
assert!(height <= vectorcraft_render::MAX_RASTER_SIDE, "{height} pixels");
assert!(b.y0 <= 10.0 && b.y1 >= 5579.0, "the image covers the strip: {b:?}");
}
#[test]
fn flatten_says_when_an_image_could_not_be_decoded() {
let _settings = settings_lock();
+25
View File
@@ -368,6 +368,31 @@ fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() {
assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image");
}
/// A transparent image longer than the renderer draws at its own resolution (32,768 pixels a
/// side) is flattened whole, at a lower resolution.
#[test]
fn a_long_transparent_image_is_flattened_whole() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 4600})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 4600})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let p = json!({"name": "i.png", "dataBase64": vectorcraft_format::base64_encode(&png(2000, 1600, [0, 0, 0, 128])), "at": [50, 50]});
s.execute("file.place", &p).unwrap();
// 10 × 4500 pt: about 600 ppi, so 37,500 pixels long.
s.execute("object.setBounds", &json!({"x": 10, "y": 10, "width": 10, "height": 4500, "reference": 0})).unwrap();
let before = [colour_at(&s.doc().unwrap().doc, 15.0, 100.0), colour_at(&s.doc().unwrap().doc, 15.0, 4400.0)];
assert!(before.iter().all(|c| c[0] < 200), "the image darkens the red: {before:?}");
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}");
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
let after = [colour_at(d, 15.0, 100.0), colour_at(d, 15.0, 4400.0)];
for (a, b) in before.iter().zip(&after) {
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{before:?} {after:?}");
}
}
/// A `size`×`size` PNG of a black disk of radius `r` pixels with an anti-aliased edge, clear
/// around it, 72 ppi.
fn disk(size: u32, r: f64) -> Vec<u8> {
+20
View File
@@ -1461,6 +1461,26 @@ fn rasterize_replaces_selection_with_image() {
assert_eq!(selected(&s), vec![img]);
}
/// Rasterize refuses art that would be longer than the renderer draws (32,768 pixels a side), and
/// the art stays as it was; at a resolution that fits, the image covers all of it.
#[test]
fn rasterize_refuses_an_image_longer_than_the_renderer_draws() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 100.0, 5569.0);
sel(&mut s, &[a]);
let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap();
let before = (undo_len(&s), node(&s, a));
let e = s.execute("object.rasterize", &json!({"ppi": 600, "padding": 0})).unwrap_err().to_string();
assert!(e.contains("too large"), "{e}");
assert_eq!((undo_len(&s), node(&s, a)), before);
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 400, "padding": 0})).unwrap());
let n = node(&s, img);
let NodeKind::Image(im) = &n.kind else { panic!() };
assert!(im.height <= vectorcraft_render::MAX_RASTER_SIDE, "{} pixels", im.height);
let b = n.geometric_bounds().unwrap();
assert!(close(b.y0, vb.y0) && b.y1 >= vb.y1, "{b:?} covers {vb:?}");
}
/// Rasterize refuses a resolution given as text or as another value that is not a number, and the
/// selected art stays as it was.
#[test]
+168
View File
@@ -980,6 +980,174 @@ fn painted_pieces_keep_to_the_effects_budget() {
assert!((350..=100_000).contains(&paths), "{paths} paths");
}
/// A clock for a document's deferrals that reads `step` milliseconds later each time it is read;
/// the test changes the step as it goes.
fn stepping_clock(step: &std::sync::Arc<std::sync::atomic::AtomicU64>) -> vectorcraft_plugins::deferred::Clock {
let (now, step) = (std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), step.clone());
std::sync::Arc::new(move || Some(now.fetch_add(step.load(std::sync::atomic::Ordering::Relaxed), std::sync::atomic::Ordering::Relaxed)))
}
/// While a document is drawn on screen, the plug-in effects of its first view share a time budget.
/// A stage left out draws its input art, is listed by `effect.deferredStatus`, and stays left out
/// on later drawings; Effect › Refresh Deferred Effects runs it again with a fresh budget, and an
/// export draws every stage. Another document has a budget of its own.
#[test]
fn the_first_view_defers_plug_in_effects_past_its_budget() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
let b = rect(&mut s, 30.0, "#ff0000");
for (id, x) in [("test.engine.deferred.a", 100.0), ("test.engine.deferred.b", 140.0)] {
let out = json!({"objects": [{"path": square_at(x)}]}).to_string();
install_wat(&mut s, &tpl::returning(&tpl::manifest(id, "effect", "{}"), &out));
}
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.deferred.a"}));
run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": "plugin.test.engine.deferred.b"}));
// Each reading is 3 s later: the first run's result comes back after the budget, and the
// second run doesn't start.
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
s.doc_mut().unwrap().plugin_effects = fx.clone();
let img = fx.view(|| drawn(&s));
fx.first_view_done();
assert_eq!((img.pixel(15, 15), img.pixel(35, 15), img.pixel(110, 50), img.pixel(150, 50)), (RED, RED, WHITE, WHITE), "the input art");
assert_eq!(fx.take_report(), Some(2));
let status = run(&mut s, "effect.deferredStatus", json!({}));
let listed = |id: NodeId, plugin: &str| json!({"objectId": id.0, "effectIndex": 0, "pluginId": plugin, "reason": "budget"});
assert_eq!(status, json!({"budgetMs": 2000, "effects": [listed(a, "test.engine.deferred.a"), listed(b, "test.engine.deferred.b")]}));
assert_eq!(fx.view(|| drawn(&s)).pixel(110, 50), WHITE, "a later drawing leaves it deferred");
// Refreshed with a fresh budget, in which the clock stands still.
assert!(s.execute("effect.refreshDeferred", &json!({"ids": ["a"]})).is_err());
assert!(s.execute("effect.refreshDeferred", &json!({"ids": [999_999]})).is_err(), "not an object of the document");
step.store(0, std::sync::atomic::Ordering::Relaxed);
let refreshed = run(&mut s, "effect.refreshDeferred", json!({"ids": [a.0]}));
assert_eq!(refreshed, json!({"refreshed": 1, "stillDeferred": 0, "failed": 0}));
let img = fx.view(|| drawn(&s));
assert_eq!((img.pixel(110, 50), img.pixel(150, 50)), (RED, WHITE));
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([listed(b, "test.engine.deferred.b")]));
// An export draws every stage; the view then uses the result.
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
assert!(svg.contains(r#"<rect x="140" y="40" width="20" height="20""#), "the square at 140: {svg}");
assert_eq!(fx.view(|| drawn(&s)).pixel(150, 50), RED);
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([]));
assert!(s.execute("effect.refreshDeferred", &json!({})).is_err(), "nothing is deferred");
// Another document: its first view has a budget of its own.
let mut other = session();
let c = rect(&mut other, 50.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(170.0)}]}).to_string();
install_wat(&mut other, &tpl::returning(&tpl::manifest("test.engine.deferred.c", "effect", "{}"), &out));
run(&mut other, "effect.apply", json!({"ids": [c.0], "effect": "plugin.test.engine.deferred.c"}));
let still = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let fx_c = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&still));
other.doc_mut().unwrap().plugin_effects = fx_c.clone();
assert_eq!(fx_c.view(|| drawn(&other)).pixel(180, 50), RED);
fx_c.first_view_done();
assert_eq!(fx_c.take_report(), Some(0));
}
/// Deferred stages are listed where their effects are: after an effect that changes no geometry,
/// and on a fill after a Transform effect that reaches its appearance, each of two instances of
/// one plug-in at its own place.
#[test]
fn deferred_stages_are_listed_at_their_effects_places() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
let b = rect(&mut s, 60.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
// An effect that paints its pieces: the object is drawn from its reshaped art.
install_wat(&mut s, &tpl::returning(&painting("test.engine.deferred.at", true), &out));
const FX: &str = "plugin.test.engine.deferred.at";
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "stylize.dropShadow"}));
for _ in 0..2 {
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": FX}));
}
run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": "distort.transform", "params": {"strokes": true}, "item": 0}));
for _ in 0..2 {
run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": FX, "item": 0}));
}
// No run has time to give a result.
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
s.doc_mut().unwrap().plugin_effects = fx.clone();
fx.view(|| drawn(&s));
let places = run(&mut s, "effect.deferredStatus", json!({}))["effects"]
.as_array()
.unwrap()
.iter()
.map(|e| (e["objectId"].as_u64().unwrap(), e["item"].as_u64(), e["effectIndex"].as_u64().unwrap()))
.collect::<Vec<_>>();
assert_eq!(places, [(a.0, None, 1), (a.0, None, 2), (b.0, Some(0), 1), (b.0, Some(0), 2)]);
}
/// A refresh that does not draw a stage (its object, or a group holding it, is hidden) leaves it
/// deferred, and a stage whose effect has been removed or hidden, or whose object has been removed,
/// is no longer listed and does not enable Effect › Refresh Deferred Effects.
#[test]
fn refresh_keeps_stages_it_did_not_draw_and_drops_removed_ones() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
let b = rect(&mut s, 30.0, "#ff0000");
let c = rect(&mut s, 50.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.kept", "effect", "{}"), &out));
run(&mut s, "effect.apply", json!({"ids": [a.0, b.0, c.0], "effect": "plugin.test.engine.deferred.kept"}));
run(&mut s, "select.set", json!({"ids": [c.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
s.doc_mut().unwrap().plugin_effects = fx.clone();
fx.view(|| drawn(&s));
fx.first_view_done();
let listed = |s: &mut Session| {
run(s, "effect.deferredStatus", json!({}))["effects"].as_array().unwrap().iter().map(|e| e["objectId"].as_u64().unwrap()).collect::<Vec<_>>()
};
assert_eq!(listed(&mut s), [a.0, b.0, c.0]);
s.execute("object.hide", &json!({"ids": [a.0, g.0]})).unwrap();
step.store(0, std::sync::atomic::Ordering::Relaxed);
let refreshed = run(&mut s, "effect.refreshDeferred", json!({"ids": [a.0, c.0]}));
assert_eq!(refreshed, json!({"refreshed": 0, "stillDeferred": 2, "failed": 0}), "a hidden object is not drawn");
assert_eq!(listed(&mut s), [a.0, b.0, c.0]);
run(&mut s, "effect.remove", json!({"ids": [a.0], "index": 0}));
assert_eq!(listed(&mut s), [b.0, c.0], "its effect was removed");
run(&mut s, "effect.setParams", json!({"ids": [b.0], "index": 0, "visible": false}));
assert_eq!(listed(&mut s), [c.0], "its effect was hidden");
s.execute("edit.clear", &json!({"ids": [b.0, g.0]})).unwrap();
assert_eq!(listed(&mut s), Vec::<u64>::new(), "its object was deleted");
assert!(s.execute("effect.refreshDeferred", &json!({})).is_err(), "nothing is deferred");
}
/// A deferred stage in a symbol's art is listed by the object in that art, and a refresh runs it
/// where the symbol is placed.
#[test]
fn deferred_stages_in_symbol_art_are_listed_and_refreshed() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.symbol", "effect", "{}"), &out));
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.deferred.symbol"}));
run(&mut s, "select.set", json!({"ids": [a.0]}));
let instance = run(&mut s, "symbol.new", json!({"name": "Deferred"}))["id"].as_u64().unwrap();
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
s.doc_mut().unwrap().plugin_effects = fx.clone();
fx.view(|| drawn(&s));
fx.first_view_done();
assert_eq!(fx.take_report(), Some(1));
let effects = run(&mut s, "effect.deferredStatus", json!({}))["effects"].clone();
let object = effects[0]["objectId"].as_u64().unwrap();
assert_eq!(effects.as_array().map(Vec::len), Some(1), "{effects}");
assert!(object != instance && s.doc().unwrap().doc.node(NodeId(object)).is_none(), "an object of the symbol's art: {effects}");
step.store(0, std::sync::atomic::Ordering::Relaxed);
assert_eq!(run(&mut s, "effect.refreshDeferred", json!({})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0}));
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([]));
}
/// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
+22
View File
@@ -130,6 +130,28 @@ fn print_as_bitmap_prints_an_image_of_each_artboard() {
assert!(crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().is_none());
}
/// Print as Bitmap of an artboard longer than the renderer draws at the raster effects resolution
/// (32,768 pixels a side) prints an image of all of it, at a lower resolution.
#[test]
fn print_as_bitmap_prints_all_of_a_long_artboard() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 9000, "height": 100})).unwrap();
s.execute("document.rasterEffectsSettings", &json!({"resolution": 300})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 9000, "height": 100})).unwrap();
let set = settings(json!({"advanced": {"printAsBitmap": true}}));
let saved = s.prefs.flattener_presets.clone();
let bitmap = crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap();
let mut placed = vec![];
bitmap.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
placed.push((im.width, im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)))));
}
});
let [(width, b)] = placed[..] else { panic!("one image: {placed:?}") };
assert!(width <= vectorcraft_render::MAX_RASTER_SIDE, "{width} pixels");
assert!(b.x0 <= 0.0 && b.x1 >= 9000.0, "the image covers the artboard: {b:?}");
}
/// Appearance of Black › Printing / Exporting (#394): Print as Bitmap draws pure CMYK black as RGB
/// black when `rich_black` is on.
#[test]
+289
View File
@@ -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<Value> {
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<Result<Value>> {
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<Value> = (0..=dialogs::MAX_ANSWERS).map(|_| json!({"kind": "alert", "value": "ok"})).collect();
let big: Vec<Value> = (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<Value> = (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);
}
+182 -5
View File
@@ -194,7 +194,6 @@ fn script_target_other_app_does_not_route() {
("#target\nvc.log(1);", "#target needs a value"),
("#targetengine \"session\"", "unsupported script directive: #targetengine"),
("#engine \"main\"", "unsupported script directive: #engine"),
("#strict on", "unsupported strict checking: #strict on"),
("#strict maybe", "#strict takes on or off"),
("#frobnicate 1", "unknown script directive: #frobnicate"),
("#include \"x.jsxinc", "unterminated quote in #include"),
@@ -223,12 +222,134 @@ fn script_name_comes_from_the_script_line() {
let _ = std::fs::remove_dir_all(base);
}
/// The script's `ro`, with a read-only `x`.
const READ_ONLY: &str = "var ro = {};\nObject.defineProperty(ro, 'x', {value: 1});\n";
#[test]
fn script_strict_on_is_refused() {
fn strict_on_runs_and_read_only_write_throws() {
let mut s = Session::new();
let e = s.execute("script.run", &json!({"source": "#strict on\nvc.log(1);"})).unwrap_err().to_string();
assert!(e.contains("unsupported strict checking: #strict on"), "{e}");
assert_eq!(s.script_report().unwrap()["kind"], "syntax");
for head in ["#strict on\n", "//@strict on\n", "#strict ON\n", "#strict off\n#strict on\n"] {
let source = format!("{head}{READ_ONLY}vc.log(1);\n ro.x = 2;\nvc.log(2);");
let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string();
assert!(e.contains("TypeError: cannot assign to property 'x': it is read-only"), "{head}: {e}");
let report = s.script_report().unwrap();
assert_eq!(report["kind"], "thrown", "{head}");
assert_eq!(report["log"], json!([1]), "{head}: the script ran up to the write");
let line = head.lines().count() as u64 + 4;
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(line), Some(3)), "{head}: {report}");
}
// The error can be caught, and every kind of assignment is checked.
let source = format!(
"#strict on\n{READ_ONLY}var out = [];\nfunction t(f) {{ try {{ f(); out.push('wrote'); }} catch (e) {{ out.push(e.name); }} }}\nt(function () {{ ro.x = 2; }});\nt(function () {{ ro['x'] += 1; }});\nt(function () {{ ro.x++; }});\nt(function () {{ undefined = 1; }});\nt(function () {{ [].concat.length = 0; }});\nvc.log(out);\nvc.log(ro.x);"
);
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(r["log"], json!([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], 1]));
// Without the line, or with `#strict off` last, the write is ignored as before.
for head in ["", "#strict off\n", "#strict on\n#strict off\n"] {
let r = s.execute("script.run", &json!({"source": format!("{head}{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")})).unwrap();
assert_eq!(r["log"], json!([1]), "{head}");
}
}
#[test]
fn strict_on_rejects_getter_only_and_nonextensible_additions() {
let mut s = Session::new();
let source = "#strict on\nvar out = [];\nfunction t(f) { try { f(); out.push('wrote'); } catch (e) { out.push(String(e)); } }\n\
var g = {get v() { return 1; }}, set = {set v(x) { this.got = x; }};\n\
t(function () { g.v = 2; });\n\
t(function () { Object.preventExtensions({}).n = 1; });\n\
t(function () { Object.seal({a: 1}).n = 1; });\n\
t(function () { Object.freeze({a: 1}).a = 2; });\n\
t(function () { vc.extra = 1; });\n\
var open = {};\nt(function () { open.n = 1; });\nt(function () { set.v = 3; });\nt(function () { Object.seal({a: 1}).a = 2; });\n\
vc.log(out);\nvc.log(open.n);\nvc.log(set.got);";
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(
r["log"],
json!([
[
"TypeError: cannot assign to property 'v': it has only a getter",
"TypeError: cannot assign to property 'n': the object is not extensible",
"TypeError: cannot assign to property 'n': the object is not extensible",
"TypeError: cannot assign to property 'a': it is read-only",
"TypeError: cannot assign to property 'extra': the object is not extensible",
"wrote",
"wrote",
"wrote"
],
1,
3
])
);
let r = s.execute("script.run", &json!({"source": source.replacen("#strict on", "#strict off", 1)})).unwrap();
assert_eq!(r["log"][0], json!(["wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote"]), "off, nothing is refused");
}
#[test]
fn strict_on_does_not_enable_es5_early_errors() {
let mut s = Session::new();
let source = "#strict on\nwith ({w: 1}) { vc.log(w); }\nfunction f(a, a) { return a; }\nvc.log(f(1, 2));\nvc.log(010);\nundeclared = 4;\nvc.log(undeclared);\n\
vc.log((function () { return typeof this; })());\nvc.log(delete Object.prototype);\nvar eval2 = 5;\nvc.log((function () { arguments = 6; return arguments; })());";
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(r["log"], json!([1, 2, 8, 4, "object", false, 6]));
// 'use strict' still makes the code strict mode code.
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nundeclared2 = 1;"})).unwrap_err().to_string();
assert!(e.contains("ReferenceError"), "{e}");
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nwith ({}) {}"})).unwrap_err().to_string();
assert_eq!(s.script_report().unwrap()["kind"], "syntax", "{e}");
}
#[test]
fn strict_on_in_include_controls_expanded_run() {
let base = folder("strict");
write(&base.join("main/on.jsxinc"), "#strict on\nvar inc = 1;");
write(&base.join("main/off.jsxinc"), "//@strict off\n");
write(&base.join("main/writes.jsxinc"), &format!("{READ_ONLY}\n ro.x = 2;\n"));
// An included file's line sets the checks for the script's own code too.
let report = failed(&base, &format!("#include \"on.jsxinc\"\n{READ_ONLY}ro.x = 2;"));
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert_eq!(report["line"], 4, "{report}");
assert!(report.get("file").is_none(), "{report}");
// A write in an included file is reported at its own line.
let report = failed(&base, "#strict on\n#include \"writes.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(4), Some(3)), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("writes.jsxinc"), "{report}");
// The last line of the expanded run wins, wherever it is.
let r = run(&base, &format!("#strict on\n#include \"off.jsxinc\"\n{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")).unwrap();
assert_eq!(r["log"], json!([1]));
let report = failed(&base, &format!("#include \"off.jsxinc\"\n#strict on\n{READ_ONLY}ro.x = 2;"));
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn strict_mode_in_eval_file_is_scoped() {
let base = folder("strict-eval");
write(
&base.join("main/on.jsx"),
&format!(
"#strict on\n{READ_ONLY}function w(o) {{ o.x = 2; }}\nvar inner;\ntry {{ ro.x = 2; inner = 'wrote'; }} catch (e) {{ inner = e.name; }}\n"
),
);
write(&base.join("main/off.jsx"), &format!("#strict off\n{READ_ONLY}ro.x = 2;\n'off ran';"));
write(&base.join("main/plain.jsx"), &format!("{READ_ONLY}ro.x = 2;\n'plain ran';"));
// A file's own `#strict on` holds for its code, its functions included, and not the caller's.
let r = run(
&base,
"$.evalFile('on.jsx');\nvar after;\ntry { ro.x = 3; after = 'ignored'; } catch (e) { after = e.name; }\nvar called;\ntry { w(ro); called = 'wrote'; } catch (e) { called = e.name; }\nvc.log([inner, after, called]);",
)
.unwrap();
assert_eq!(r["log"], json!([["TypeError", "ignored", "TypeError"]]));
// A file without the line takes the caller's checks, and its own `#strict off` turns them off.
let r = run(&base, "#strict on\nvc.log($.evalFile('off.jsx'));\nvar e1;\ntry { ro.x = 2; } catch (e) { e1 = e.name; }\nvc.log(e1);").unwrap();
assert_eq!(r["log"], json!(["off ran", "TypeError"]), "the caller keeps its own checks");
let report = failed(&base, "#strict on\n$.evalFile('plain.jsx');");
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("plain.jsx"), "{report}");
assert_eq!(report["line"], 3, "{report}");
assert_eq!(run(&base, "vc.log($.evalFile('plain.jsx'));").unwrap()["log"], json!(["plain ran"]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
@@ -332,3 +453,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<Result<Value>> {
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);
}
+11 -1
View File
@@ -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::<Vec<_>>(), ["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::<Vec<_>>(), ["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());
+77
View File
@@ -207,6 +207,83 @@ fn deleting_a_filter_in_use_leaves_the_effect_drawing_nothing() {
assert_eq!(drawn(&s, a), json!(SHIFT_MORE));
}
/// The `(name, xml)` of every `svg.filter` effect on the document's objects.
fn filter_effects(s: &Session) -> Vec<(String, String)> {
let mut out = vec![];
s.doc().unwrap().doc.walk(|n| {
for e in n.appearance.effects.iter().filter(|e| e.id == vectorcraft_render::effects::SVG_FILTER) {
out.push((e.params["name"].as_str().unwrap().to_string(), e.params["xml"].as_str().unwrap().to_string()));
}
});
out
}
/// Pasted art whose filter has a name the document gives another filter keeps its own filter
/// under a free name, so editing or deleting the document's filter leaves it alone. Pasted back
/// into the document it came from, it takes the document's filter as it is now.
#[test]
fn pasted_art_keeps_a_filter_another_of_its_name_would_replace() {
let mut s = session();
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{SHIFT_MORE}</defs><rect width="30" height="30" filter="url(#Shift)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let pasted = filter_effects(&s);
assert_eq!(pasted.len(), 1);
assert_eq!(pasted[0].0, "Shift 2", "renamed: {pasted:?}");
assert!(pasted[0].1.contains("id=\"Shift 2\"") && pasted[0].1.contains("dx=\"9\""), "its own filter: {pasted:?}");
assert_eq!(names(&mut s), ["Shift"]);
run(&mut s, "svgFilter.delete", json!({"name": "Shift"}));
assert_eq!(filter_effects(&s), pasted, "the document's filter leaves it alone");
// The same filter as the document's keeps its name.
let mut t = session();
run(&mut t, "svgFilter.define", json!({"xml": SHIFT}));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{SHIFT}</defs><rect width="30" height="30" filter="url(#Shift)"/></svg>"#
);
run(&mut t, "clipboard.importSvg", json!({"svg": svg}));
run(&mut t, "edit.pasteInPlace", json!({}));
assert_eq!(filter_effects(&t), [("Shift".to_string(), SHIFT.to_string())]);
// Copied, then the filter edited: pasted back, the copy draws the edited filter.
let mut u = session();
let a = rect(&mut u, 10.0);
run(&mut u, "svgFilter.define", json!({"xml": SHIFT}));
run(&mut u, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]}));
run(&mut u, "select.set", json!({"ids": [a.0]}));
run(&mut u, "edit.copy", json!({}));
run(&mut u, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"}));
run(&mut u, "edit.pasteInPlace", json!({}));
let both = filter_effects(&u);
assert_eq!(both, [("Shift".to_string(), SHIFT_MORE.to_string()), ("Shift".to_string(), SHIFT_MORE.to_string())]);
}
/// A pasted filter at the size limit, which "Shift 2" would make too long to draw, keeps its own
/// definition under a free name no longer than its own (a free letter for a one-letter name), and
/// its id is that name.
#[test]
fn pasted_art_with_a_filter_at_the_size_limit_still_draws_when_renamed() {
for (name, held, renamed) in [("Shift", SHIFT, "Shif"), ("S", r#"<filter id="S"><feOffset dx="3" dy="3"/></filter>"#, "a")] {
let mut s = session();
run(&mut s, "svgFilter.define", json!({"xml": held}));
let (open, close) = (format!(r#"<filter id="{name}"><feOffset dx="9" dy="9" result=""#), r#""/></filter>"#);
let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len()));
assert!(vectorcraft_render::effects::svg_filter::check(&big).is_ok());
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{big}</defs><rect width="30" height="30" filter="url(#{name})"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let pasted = filter_effects(&s);
assert_eq!(pasted.len(), 1);
assert_eq!(pasted[0].0, renamed, "renamed: {}", pasted[0].0);
assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok(renamed.to_string()), "the pasted filter draws under its name");
assert!(pasted[0].1.contains("dx=\"9\""), "its own filter");
assert_eq!(names(&mut s), [name]);
}
}
/// Art in a symbol's definition that uses a filter follows its edits too.
#[test]
fn a_symbols_art_follows_a_filter_edit() {
+31 -1
View File
@@ -84,7 +84,7 @@ fn the_builtin_library_is_original_art_generated_in_code() {
let mut s = Session::new();
let list = run(&mut s, "symbol.libraries", json!({}));
let libs = list["libraries"].as_array().unwrap();
assert_eq!(libs.len(), 3, "{libs:?}");
assert_eq!(libs.len(), 4, "{libs:?}");
assert_eq!((libs[0]["id"].as_str(), libs[0]["category"].as_str()), (Some("basic-shapes"), Some("builtIn")));
assert_eq!(
(libs[1]["id"].as_str(), libs[1]["name"].as_str(), libs[1]["category"].as_str()),
@@ -94,6 +94,10 @@ fn the_builtin_library_is_original_art_generated_in_code() {
(libs[2]["id"].as_str(), libs[2]["name"].as_str(), libs[2]["category"].as_str()),
(Some("map-markers"), Some("Map Markers"), Some("builtIn"))
);
assert_eq!(
(libs[3]["id"].as_str(), libs[3]["name"].as_str(), libs[3]["category"].as_str()),
(Some("inventory-icons"), Some("Inventory Icons"), Some("builtIn"))
);
let lib = run(&mut s, "symbol.library", json!({"library": "basic shapes"}));
let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Circle", "Square", "Rounded Square", "Triangle", "Diamond", "Hexagon", "Star", "Burst", "Arrow"]);
@@ -129,6 +133,32 @@ fn ui_frames_symbols_carry_default_nine_slice_when_added() {
));
}
#[test]
fn inventory_icons_symbols_add_without_nine_slice() {
let mut s = session();
let lib = run(&mut s, "symbol.library", json!({"library": "inventory-icons"}));
let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Sword", "Shield", "Potion", "Key", "Gem"]);
let mut placed = Vec::new();
for (i, name) in names.iter().enumerate() {
let r = run(
&mut s,
"symbol.addFromLibrary",
json!({"library": "inventory-icons", "name": name, "place": true, "x": 100.0 + 80.0 * i as f64, "y": 100.0}),
);
assert_eq!(r["added"], json!([name]));
placed.push(r["placed"].as_u64().unwrap());
}
let d = doc(&s);
for name in &names {
assert_eq!(d.symbol_nine_slice(name), None, "{name} should not carry 9-slice guides");
}
assert!(matches!(
d.node(NodeId(placed[0])).map(|n| &n.kind),
Some(NodeKind::SymbolInstance { symbol, .. }) if symbol == "Sword"
));
}
#[test]
fn map_markers_symbols_add_without_nine_slice() {
let mut s = session();
+69
View File
@@ -3467,6 +3467,75 @@ fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() {
assert_eq!(query["chosen"], json!(glyph.gid), "an overflowing character reports the fallback glyph it is drawn with");
}
/// `text.setGlyphAlternate` with `gid: null` reports the glyph the character is drawn with, as
/// `text.glyphAlternates` does: the fallback face's glyph for a character the font lacks, laid out
/// or overflowing, and the capital that All Caps draws for an a.
#[test]
fn a_glyph_choice_taken_away_reports_the_glyph_drawn() {
use vectorcraft_text::test_fonts::{alternates_family, alternates_font};
let _fonts = crate::adding_fonts();
let db = vectorcraft_text::FontDb::global();
db.add_font(alternates_font(true).expect("the test font builds"));
let face = db.face(alternates_family(true), "Regular").expect("the test face");
let mut s = session();
let reset = |s: &mut Session, id: NodeId, start: usize| {
s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": null})).unwrap()["gid"].clone()
};
let chosen = |s: &mut Session, id: NodeId, start: usize| {
s.execute("text.glyphAlternates", &json!({"id": id.0, "start": start})).unwrap()["chosen"].clone()
};
let ch = 'Ƃ';
assert_eq!(face.glyph_for(ch), 0);
let point = json!({"x": 0, "y": 0, "text": ch.to_string(), "font": alternates_family(true)});
let id = NodeId(s.execute("text.create", &point).unwrap()["id"].as_u64().unwrap());
let drawn = vectorcraft_text::layout(db, &obj(&s, id)).glyphs[0].gid;
assert_ne!(drawn, 0);
assert_eq!(reset(&mut s, id, 0), json!(drawn), "the fallback glyph");
assert_eq!(chosen(&mut s, id, 0), json!(drawn));
let area = json!({"x": 0, "y": 300, "text": ch.to_string(), "font": alternates_family(true), "area": {"width": 200, "height": 2}});
let boxed = NodeId(s.execute("text.create", &area).unwrap()["id"].as_u64().unwrap());
assert!(vectorcraft_text::layout(db, &obj(&s, boxed)).glyphs.is_empty(), "the text overflows");
assert_eq!(reset(&mut s, boxed, 0), json!(drawn), "the overflowing character's fallback glyph");
let caps = NodeId(
s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xaay", "size": 20, "font": alternates_family(true)})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("text.setRangeStyle", &json!({"id": caps.0, "start": 0, "end": 4, "allCaps": true})).unwrap();
let drawn = vectorcraft_text::layout(db, &obj(&s, caps)).glyphs.iter().find(|g| g.byte == 1).expect("the a is laid out").gid;
assert_ne!(drawn, face.glyph_for('a'), "All Caps draws the a as A");
assert_eq!(reset(&mut s, caps, 1), json!(drawn), "the capital drawn");
assert_eq!(chosen(&mut s, caps, 1), json!(drawn));
}
/// A run that holds a choice for its first character and goes on past it, as a file may have,
/// draws Source Serif 4's fi ligature for the f and the i after that character. `gid: null` for the
/// i leaves the text as it is and reports the ligature, as `text.glyphAlternates` does.
#[test]
fn a_glyph_choice_taken_away_inside_a_ligature_reports_the_ligature() {
let db = vectorcraft_text::FontDb::global();
let face = db.face("Source Serif 4", "Regular").expect("the bundled face");
let mut s = session();
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xfin", "size": 40, "font": "Source Serif 4"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let mut runs = obj(&s, id).runs;
runs[0].style.glyph_alternate = Some(face.choice('x', face.glyph_for('x')));
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(text)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("text") };
text.runs = runs.clone();
let ligature = vectorcraft_text::layout(db, &obj(&s, id)).glyphs.iter().find(|g| g.byte == 1 && g.len == 2).expect("f + i is one glyph").gid;
let chosen = s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap()["chosen"].clone();
assert_eq!(chosen, json!(ligature));
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 2, "gid": null})).unwrap();
assert_eq!(r["gid"], chosen, "the ligature drawn for the i");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no edit");
assert_eq!(obj(&s, id).runs, runs, "the runs are kept");
}
/// A fallback face draws Ka with the vowel sign I in one cluster, the vowel sign first. The glyph
/// reported for Ka is the fallback face's glyph for Ka, laid out or overflowing, not the vowel
/// sign's.
+9 -3
View File
@@ -708,6 +708,14 @@ impl Session {
Ok(())
}
/// The artboard a guide made from a ruler belongs to: with the Artboard tool, its active
/// artboard (an artboard guide); with any other tool, none (a canvas guide).
pub fn ruler_guide_artboard(&self) -> Option<usize> {
let boards = self.active().map_or(0, |d| d.doc.artboards.len());
let active = (self.tool.id() == "artboard").then(|| self.tool.options()["active"].as_u64()).flatten();
active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards)
}
/// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a
/// horizontal one out of the top ruler. `ev` is the pointer in document space (a drag, then the
/// release), over the canvas or not (`on_canvas`). The guide shows where the pointer is over
@@ -722,11 +730,9 @@ impl Session {
let g = match self.ruler_guide.take() {
Some(g) => Some(g),
None => {
let boards = self.active().map_or(0, |d| d.doc.artboards.len());
let artboard_tool = self.tool.id() == "artboard";
let swap = self.prefs.cmd_ruler_drag_artboard_guides && ev.mods.cmd;
let active = artboard_tool.then(|| self.tool.options()["active"].as_u64()).flatten();
let artboard = active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards);
let artboard = self.ruler_guide_artboard();
let view_board = view.artboard;
self.with_tool_cx(view, |_, cx| {
Some(match (swap, artboard_tool) {
+52 -15
View File
@@ -9,10 +9,11 @@ use vectorcraft_engine::{Session, command_specs};
use vectorcraft_testkit::catch_quiet;
use vectorcraft_testkit::fixtures::Fixture;
use vectorcraft_testkit::invariants::{check_all, check_session, doc_json};
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys};
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys, safe_value};
/// Commands that replace/close the active document or touch the filesystem are fine to call, but a
/// few need their paths redirected into a temp dir (handled by `junk_params`).
/// few need their file and folder params redirected into a temp dir (`safe_value`, which
/// `junk_params` applies).
fn safe_path() -> String {
vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string()
}
@@ -230,6 +231,54 @@ fn tool_edit_state(s: &Session) -> ToolEditState {
(docs, s.active_index(), s.paint.clone())
}
/// The params the tool-edit sweep sends `c`: junk params, then each documented key turned on and
/// off, with a string under a file or folder key replaced by a location inside `path`'s folder.
fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Value> {
let mut cases = junk_params(c.params, path);
if heavy(c.id) {
cases.truncate(12);
}
for k in param_keys(c.params) {
for v in [json!(true), json!(false), json!(1), json!("on")] {
cases.push(json!({ k.as_str(): safe_value(&k, &v, path) }));
}
}
// Find Objects selects only with a filter, which one key at a time does not give it.
if c.id == "document.find" {
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
}
cases
}
/// Every string the sweeps send under a key that names a file or folder to write is inside the
/// sweep's temp folder, so no run writes into the working directory.
#[test]
fn sweep_cases_name_files_only_inside_the_temp_folder() {
let path = safe_path();
let dir = vectorcraft_testkit::temp_dir("sweep");
let keys = ["path", "diffPath", "folder", "destinationFolder"];
let mut seen = std::collections::BTreeSet::new();
let mut outside = std::collections::BTreeSet::new();
for c in command_specs() {
let mut cases = junk_params(c.params, &path);
cases.extend(tool_edit_cases(c, &path));
for p in &cases {
let Some(o) = p.as_object() else { continue };
for (k, v) in o.iter().filter(|(k, _)| keys.contains(&k.as_str())) {
seen.insert(k.clone());
if let Some(s) = v.as_str()
&& !std::path::Path::new(s).starts_with(&dir)
{
outside.insert(format!("{} {{\"{k}\": {v}}}", c.id));
}
}
}
}
assert_eq!(seen.len(), keys.len(), "the sweeps send only {seen:?}");
let outside: Vec<String> = outside.into_iter().collect();
assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n"));
}
/// Every command that runs at once while the active tool computes an edit on another thread (a
/// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the
/// documents' revisions and selections, the active document and the current paint as they were,
@@ -241,19 +290,7 @@ fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() {
let mut failures = vec![];
for fx in [Fixture::Single, Fixture::Multi] {
for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) {
let mut cases = junk_params(c.params, &path);
if heavy(c.id) {
cases.truncate(12);
}
for k in param_keys(c.params) {
for v in [json!(true), json!(false), json!(1), json!("on")] {
cases.push(json!({ k.as_str(): v }));
}
}
// Find Objects selects only with a filter, which one key at a time does not give it.
if c.id == "document.find" {
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
}
let cases = tool_edit_cases(c, &path);
for p in &cases {
let mut s = fx.session();
let before = tool_edit_state(&s);
+20
View File
@@ -663,6 +663,26 @@ proptest! {
survive(&svg, || vectorcraft_svg::import(&svg).ok())?;
}
/// Clip paths made of text, of `<use>` 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!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"><defs><text id=\"t\" font-size=\"20\"{}>ab<tspan dy=\"3\">c</tspan></text></defs><clipPath id=\"e\"{}><text y=\"30\"{}>Clip<tspan x=\"5\" y=\"60\">it</tspan></text><use href=\"#t\"{}/><rect width=\"5\" height=\"5\"/></clipPath><clipPath id=\"f\" clip-path=\"url(#e)\"><use xlink:href=\"#t\" x=\"9\"/></clipPath><rect width=\"90\" height=\"90\" clip-path=\"url(#e)\"/><g clip-path=\"url(#f)\">{}</g></svg>",
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,
+222 -27
View File
@@ -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<u64> = {
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!({}));
+40
View File
@@ -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");
}
+23 -1
View File
@@ -3494,6 +3494,9 @@ fn typed() -> Document {
v_scale: 90.0,
stroke: solid(Color::rgb(0.0, 0.5, 0.0)),
stroke_width: 0.5,
stroke_cap: vectorcraft_doc::LineCap::Square,
stroke_join: vectorcraft_doc::LineJoin::Bevel,
stroke_miter_limit: 2.5,
all_caps: true,
..regular.clone()
};
@@ -3610,7 +3613,7 @@ fn type_lines(doc: &Document) -> Vec<String> {
for r in &t.runs {
let s = &r.style;
out.push(format!(
" run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} caps={} small={:?} tcy={} roman={}",
" run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} {:?} {:?} {} caps={} small={:?} tcy={} roman={}",
r.text,
s.font_family,
s.font_style,
@@ -3623,6 +3626,9 @@ fn type_lines(doc: &Document) -> Vec<String> {
paint(&s.fill),
paint(&s.stroke),
r3(s.stroke_width),
s.stroke_cap,
s.stroke_join,
r3(s.stroke_miter_limit),
s.all_caps,
s.small_caps.map(r3),
s.tate_chu_yoko,
@@ -3763,6 +3769,22 @@ fn type_comes_back_as_type() {
assert_eq!(back.text_threads.len(), 1, "the thread comes back");
}
/// A run without a stroke whose stroke's cap, join and miter limit aren't the defaults loses
/// nothing that draws: the save reports no attributes left out.
#[test]
fn an_unstroked_run_with_a_stroke_style_loses_nothing() {
use vectorcraft_doc::{CharStyle, LineCap, LineJoin, TextObject};
for miter in [3.0, f64::NAN] {
let st = CharStyle { stroke_cap: LineCap::Round, stroke_join: LineJoin::Bevel, stroke_miter_limit: miter, ..style(14.0, Color::BLACK) };
let mut doc = Document::new(200.0, 200.0);
let layer = doc.layers[0].id;
let n = text_node(&mut doc, TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "Plain", st));
doc.insert(Some(layer), usize::MAX, n).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.is_empty(), "{miter}: {:?}", out.warnings);
}
}
#[test]
fn type_is_written_again_as_the_same_bytes() {
let doc = typed();
+51 -5
View File
@@ -21,15 +21,15 @@ use std::fmt::Write;
use kurbo::{ParamCurveArclen, PathSeg};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{
AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, Mojikumi, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign,
PathEffect, TextKind, TextObject, VerticalAlign,
AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, LineCap, LineJoin, Mojikumi, Node, NodeId, NodeKind, ParaDirection,
ParaStyle, PathAlign, PathEffect, TextKind, TextObject, VerticalAlign,
};
use vectorcraft_geom::{Affine, PathData, Point};
use crate::ps;
/// What the text document holds of type, and what it leaves out.
pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and stroke weight, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps";
pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and its weight, cap, join and miter limit, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps";
/// The version of the text document's form (`/98 << /0 n >>`), as the newest files users sent have it.
const STREAM_VERSION: u32 = 14;
@@ -347,6 +347,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
fill: unlinked(&s.fill),
stroke: unlinked(&s.stroke),
stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width },
stroke_cap: if stroked { s.stroke_cap } else { CharStyle::default().stroke_cap },
stroke_join: if stroked { s.stroke_join } else { CharStyle::default().stroke_join },
stroke_miter_limit: if stroked { miter_limit(s.stroke_miter_limit) } else { CharStyle::default().stroke_miter_limit },
all_caps: s.all_caps,
small_caps: s.small_caps.filter(|v| *v == default_small_caps),
tate_chu_yoko: s.tate_chu_yoko,
@@ -357,7 +360,19 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
};
let run_lost = |s: &CharStyle| {
let stroked = !s.stroke.is_none();
let s = CharStyle { stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width }, ..s.clone() };
// Without a stroke, its weight, cap, join and miter limit draw nothing: nothing is lost.
let unstroked = CharStyle::default();
let s = if stroked {
s.clone()
} else {
CharStyle {
stroke_width: unstroked.stroke_width,
stroke_cap: unstroked.stroke_cap,
stroke_join: unstroked.stroke_join,
stroke_miter_limit: unstroked.stroke_miter_limit,
..s.clone()
}
};
// A Lab color is written as RGB.
let lab = |p: &Paint| matches!(p, Paint::Solid { color: Color::Lab { .. }, .. });
s != held_char(&s) || lab(&s.fill) || lab(&s.stroke) || !font_kept(&s)
@@ -463,6 +478,32 @@ fn font_name(st: &CharStyle) -> FontRecord {
}
}
/// The text document's number for a stroke's cap (key 61), as PostScript numbers them: 0 butt, 1
/// round, 2 projecting.
fn cap_code(cap: LineCap) -> i64 {
match cap {
LineCap::Butt => 0,
LineCap::Round => 1,
LineCap::Square => 2,
}
}
/// The text document's number for a stroke's join (key 62), as PostScript numbers them: 0 miter, 1
/// round, 2 bevel.
fn join_code(join: LineJoin) -> i64 {
match join {
LineJoin::Miter => 0,
LineJoin::Round => 1,
LineJoin::Bevel => 2,
}
}
/// A stroke's miter limit (key 64) as the reader takes it back: at least 1 and at most 500, 10 when
/// it isn't a number at least 1.
fn miter_limit(v: f64) -> f64 {
if v.is_finite() && v >= 1.0 { v.min(500.0) } else { 10.0 }
}
/// `v` as the 16.16 fixed-point number a design vector holds (`wght` 700 is 45875200).
fn fixed(v: f32) -> i64 {
if v.is_finite() { (f64::from(v) * 65536.0).round() as i64 } else { 0 }
@@ -621,7 +662,12 @@ impl TextDoc {
}
d.extend([(56, V::Bool(fill.is_some())), (57, V::Bool(stroke.is_some()))]);
if stroke.is_some() {
d.push((63, V::Small(st.stroke_width / scale)));
d.extend([
(61, V::Int(cap_code(st.stroke_cap))),
(62, V::Int(join_code(st.stroke_join))),
(63, V::Small(st.stroke_width / scale)),
(64, real(miter_limit(st.stroke_miter_limit))),
]);
}
dict(d)
}
+1 -1
View File
@@ -472,7 +472,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe
Ok(p) => p,
Err(e) => return Err(interp.throw(ErrorType::Syntax, &e.message)),
};
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag });
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag, strict_writes: interp.writes_mode() });
let code = match compile_constructed_function(&program, &source) {
Ok(Some(code)) => code,
Ok(None) => return Err(interp.throw(ErrorType::Syntax, "the parameters or body do not form one function")),
+3
View File
@@ -173,6 +173,9 @@ pub(crate) struct Source {
pub(crate) text: Rc<[u16]>,
/// Which source this is, as [`crate::Realm::error_source`] gives it.
pub(crate) tag: u32,
/// Writes to read-only and getter-only properties, and new properties on objects that are
/// not extensible, throw in this source's code even where it is not strict mode code.
pub(crate) strict_writes: bool,
}
#[derive(Debug)]
+5 -5
View File
@@ -175,16 +175,16 @@ 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<Code>, env: EnvId },
Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str },
Host { f: Rc<HostFn>, name: Rc<str> },
Data { f: DataFn, name: Rc<str>, text: bool },
Eval { f: DataFn, end: Rc<dyn Fn()>, name: Rc<str> },
Data { f: DataFn, name: Rc<str>, read: crate::host::ArgRead },
Eval { f: DataFn, end: Rc<dyn Fn()>, strict_writes: Rc<dyn Fn() -> Option<bool>>, name: Rc<str> },
Bound { target: ObjId, this: Value, args: Rc<[Value]> },
}
+101 -23
View File
@@ -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<dyn Fn(Vec<Data>) -> Result<Data, HostError>>;
/// 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<Arg>, HostFunction),
/// A property whose value the function gives each time it is read.
Getter(HostFunction),
/// A frozen object with these members.
@@ -81,10 +111,12 @@ pub enum HostItem {
/// the call gives that code's completion value, and its declarations become globals. A result
/// that is not a string is the call's result. `end` is called when the source has run,
/// normally or not. Each source given has the next tag of [`crate::Realm::error_source`],
/// from 1.
/// from 1. `strict_writes`, called after `source`, gives [`crate::Realm::set_strict_writes`]
/// for that source alone; with `None` it takes the calling code's.
Evaluator {
source: HostFunction,
end: Rc<dyn Fn()>,
strict_writes: Rc<dyn Fn() -> Option<bool>>,
},
}
@@ -289,12 +321,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<str>, args: &[Value], text: bool) -> JsResult<Value> {
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<str>, args: &[Value], how: &ArgRead) -> JsResult<Value> {
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)?),
}
}
@@ -302,17 +345,26 @@ impl Interp {
/// Calls evaluator `f` ([`HostItem::Evaluator`]): reads its arguments, runs it, and runs the
/// source it gives as global code, as an indirect `eval` does, giving that code's completion
/// 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<dyn Fn()>, name: &Rc<str>, args: &[Value]) -> JsResult<Value> {
let data = self.read_args(name, args, false)?;
/// the source has run, normally or not. `strict_writes` gives the source's
/// [`crate::bytecode::Source::strict_writes`], or `None` for the calling code's.
pub(crate) fn call_evaluator(
&mut self,
f: &crate::heap::DataFn,
end: &Rc<dyn Fn()>,
strict_writes: &Rc<dyn Fn() -> Option<bool>>,
name: &Rc<str>,
args: &[Value],
) -> JsResult<Value> {
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),
Err(e) => return Err(self.host_error(e)?),
};
let writes = strict_writes().unwrap_or_else(|| self.writes_mode());
self.evaluated = self.evaluated.saturating_add(1);
let tag = std::mem::replace(&mut self.source_tag, self.evaluated);
let code = self.encode_source(&source).and_then(|units| self.compile_source(&units, false, crate::bytecode::CodeKind::Eval));
let code = self.encode_source(&source).and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval));
self.source_tag = tag;
let r = match code {
Ok(code) => self.run_global(code, true),
@@ -322,20 +374,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<str>, args: &[Value], text: bool) -> JsResult<Vec<Data>> {
/// The arguments of host function `name` as [`Data`], read as `how` says.
fn read_args(&mut self, name: &Rc<str>, args: &[Value], how: &ArgRead) -> JsResult<Vec<Data>> {
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 +413,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<Abrupt> {
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,22 +473,34 @@ 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::Evaluator { source, end } => {
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, strict_writes } => {
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)));
let func = Func::Eval { f: source, end, strict_writes, name: full.into() };
let f = self.heap.alloc_object(Object::new(Class::Function(func), Some(proto)));
Prop::Data { value: Value::Object(f), flags: ENUMERABLE }
}
};
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<Arg>, 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)))
}
}
+78
View File
@@ -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<Cell<u32>> {
let calls: Rc<Cell<u32>> = 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<RefCell<Vec<Vec<Data>>>> = 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]
+47 -9
View File
@@ -181,6 +181,12 @@ pub(crate) struct Interp {
pub(crate) held: Rc<HeldCount>,
/// Set by the host to end the current slice of a resumable run at the next safe point.
pub(crate) pause: Rc<std::cell::Cell<bool>>,
/// 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<std::cell::Cell<bool>>,
/// 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<String>,
/// How much a value passed to or from a host function may carry.
@@ -191,6 +197,8 @@ pub(crate) struct Interp {
pub(crate) evaluated: u32,
/// The tag of the source the last run's error was raised in.
pub(crate) error_tag: u32,
/// [`crate::bytecode::Source::strict_writes`] for the scripts the realm runs.
pub(crate) strict_writes: bool,
}
/// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's
@@ -380,11 +388,15 @@ 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,
evaluated: 0,
error_tag: crate::realm::UNKNOWN_SOURCE,
strict_writes: false,
};
crate::builtins::install(&mut interp);
interp.fuel = limits.fuel;
@@ -423,7 +435,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(())
}
@@ -691,6 +703,18 @@ impl Interp {
self.frames.last().is_some_and(|f| f.code.strict)
}
/// Does a property write the running code makes throw when it cannot be made? It does in
/// strict mode code and in code from a source with [`crate::bytecode::Source::strict_writes`].
fn strict_writes(&self) -> bool {
self.frames.last().is_some_and(|f| f.code.strict || f.code.source.strict_writes)
}
/// [`crate::bytecode::Source::strict_writes`] for source compiled now: the running code's,
/// or the realm's between runs.
pub(crate) fn writes_mode(&self) -> bool {
self.frames.last().map_or(self.strict_writes, |f| f.code.source.strict_writes)
}
fn exec(&mut self, op: Op) -> JsResult<()> {
match op {
Op::Undefined => self.push(Value::Undefined),
@@ -909,11 +933,14 @@ impl Interp {
_ => Ok(()),
}
}
Resolved::Obj { object, .. } => self.put(object, name.clone(), v, strict),
Resolved::Obj { object, .. } => {
let throw = self.strict_writes();
self.put(object, name.clone(), v, throw)
}
Resolved::Unresolved if strict => Err(self.not_defined(name)),
Resolved::Unresolved => {
let global = self.global;
self.put(global, name.clone(), v, false)
let (global, throw) = (self.global, self.strict_writes());
self.put(global, name.clone(), v, throw)
}
}
}
@@ -1444,7 +1471,10 @@ impl Interp {
fn put_value(&mut self, base: &Value, k: Key, v: Value) -> JsResult<()> {
let strict = self.strict();
match base {
Value::Object(o) => self.put(*o, k, v, strict),
Value::Object(o) => {
let throw = self.strict_writes();
self.put(*o, k, v, throw)
}
Value::Undefined | Value::Null => {
let message = format!(
"cannot set property '{}' of {}",
@@ -1855,12 +1885,14 @@ 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, strict_writes, name } => {
return Ok(Prepared::Done(self.call_evaluator(&f, &end, &strict_writes, &name, &args)?));
}
Func::Eval { f, end, name } => return Ok(Prepared::Done(self.call_evaluator(&f, &end, &name, &args)?)),
Func::Host { f: host, .. } => {
let js_args: Vec<crate::JsValue> = args.iter().map(|v| self.to_js_value(v)).collect();
return match host(&js_args) {
@@ -2129,11 +2161,17 @@ impl Interp {
/// Parses and compiles source for eval code or a script, counting it against the source
/// limit.
pub(crate) fn compile_source(&mut self, units: &[u16], strict: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> {
let writes = self.writes_mode();
self.compile_source_writes(units, strict, writes, kind)
}
/// [`Interp::compile_source`], with [`crate::bytecode::Source::strict_writes`] set to `writes`.
pub(crate) fn compile_source_writes(&mut self, units: &[u16], strict: bool, writes: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> {
self.source_units = self.source_units.saturating_add(units.len());
let remaining = self.options.max_source_units.saturating_sub(self.source_units.saturating_sub(units.len()));
let options = Options { strict, max_source_units: remaining, ..self.options.clone() };
let program = crate::parse_utf16(units, &options).map_err(CompileError::Syntax)?;
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag });
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag, strict_writes: writes });
compile_program(&program, &source, kind).map_err(|e| match e {
CompileFail::Syntax(s) => CompileError::Syntax(s),
CompileFail::Internal(m) => CompileError::Internal(m),
+56 -1
View File
@@ -691,6 +691,61 @@ fn a_strict_realm_runs_scripts_as_strict_mode_code() {
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
}
#[test]
fn strict_writes_are_separate_from_es5_strict() {
let setup = "var ro = {}; Object.defineProperty(ro, 'x', {value: 1}); \
var getter = {get g() { return 1; }}; var shut = Object.preventExtensions({a: 1}); var open = {};";
let mut realm = Realm::new();
realm.set_strict_writes(true);
realm.run(setup).unwrap();
for (src, why) in [
("ro.x = 2", "cannot assign to property 'x': it is read-only"),
("ro['x'] += 1", "cannot assign to property 'x': it is read-only"),
("ro.x++", "cannot assign to property 'x': it is read-only"),
("getter.g = 2", "cannot assign to property 'g': it has only a getter"),
("shut.b = 2", "cannot assign to property 'b': the object is not extensible"),
("Object.create(ro).x = 2", "cannot assign to property 'x': it is read-only"),
("NaN = 1", "cannot assign to property 'NaN': it is read-only"),
("(function () { ro.x = 2; })()", "cannot assign to property 'x': it is read-only"),
("eval('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
("(0, eval)('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
("Function('ro.x = 2')()", "cannot assign to property 'x': it is read-only"),
] {
let e = realm.run(src).unwrap_err();
assert_eq!(e.kind, RunErrorKind::Thrown, "{src}");
assert!(e.message.contains("TypeError") && e.message.contains(why), "{src}: {}", e.message);
}
// Writes that can be made, and the rest of strict mode, are as before.
assert_eq!(realm.run("open.n = 1; shut.a = 5; open.n + shut.a"), Ok(JsValue::Number(6.0)));
assert_eq!(realm.run("with ({w: 2}) { w } "), Ok(JsValue::Number(2.0)));
assert_eq!(realm.run("(function (a, a) { return a; })(1, 2)"), Ok(JsValue::Number(2.0)));
assert_eq!(realm.run("010"), Ok(JsValue::Number(8.0)));
assert_eq!(realm.run("undeclared = 3; undeclared"), Ok(JsValue::Number(3.0)));
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
assert_eq!(realm.run("delete Object.prototype"), Ok(JsValue::Bool(false)));
assert_eq!(realm.run("'abc'.length = 1; 'abc'.extra = 1; 1"), Ok(JsValue::Number(1.0)), "a primitive's properties as in sloppy mode");
// 'use strict' keeps all of ES5 strict mode.
assert_eq!(realm.run("'use strict'; with ({}) {}").map_err(|e| e.kind), Err(RunErrorKind::Syntax));
assert_eq!(realm.run("'use strict'; delete Object.prototype").map_err(|e| e.kind), Err(RunErrorKind::Thrown));
// Off, the same writes are ignored, as in sloppy mode code.
realm.set_strict_writes(false);
assert_eq!(realm.run("ro.x = 2; getter.g = 2; shut.b = 2; NaN = 1; ro.x + getter.g + String(shut.b)"), Ok(JsValue::String("2undefined".into())));
// A function compiled with the writes on keeps them when called from code without.
let mut realm = Realm::new();
realm.set_strict_writes(true);
realm.run(&format!("{setup} function w() {{ ro.x = 2; }}")).unwrap();
realm.set_strict_writes(false);
assert!(realm.run("w()").unwrap_err().message.contains("it is read-only"));
assert_eq!(realm.run("ro.x = 3; ro.x"), Ok(JsValue::Number(1.0)));
// An undeclared name added to a global object that is not extensible.
let mut realm = Realm::new();
realm.set_strict_writes(true);
let e = realm.run("Object.preventExtensions(this); newName = 1").unwrap_err();
assert!(e.message.contains("cannot assign to property 'newName': the object is not extensible"), "{}", e.message);
let mut realm = Realm::new();
assert_eq!(realm.run("Object.preventExtensions(this); newName = 1; typeof newName"), Ok(JsValue::String("undefined".into())));
}
#[test]
fn an_apply_that_throws_while_reading_its_arguments_keeps_nothing_alive() {
// Each call reads a fresh array at index 0 before index 1 throws; the arrays must not stay
@@ -774,7 +829,7 @@ fn deep_nesting_compiles_on_a_small_stack() {
// ---- the verifier ----
fn code(ops: Vec<Op>) -> Code {
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0 });
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0, strict_writes: false });
Code {
positions: vec![0; ops.len()],
ops,
+1 -1
View File
@@ -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};
+59 -1
View File
@@ -174,6 +174,14 @@ impl Realm {
self.unbroken_fuel = fuel;
}
/// With `on`, the scripts run next (and the code they define and evaluate) throw a
/// `TypeError` when they assign to a read-only or getter-only property or add a property to
/// an object that is not extensible, as strict mode code does, without the rest of strict
/// mode. Off by default.
pub fn set_strict_writes(&mut self, on: bool) {
self.interp.strict_writes = on;
}
pub fn limits(&self) -> &Limits {
&self.interp.limits
}
@@ -222,6 +230,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 +256,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 +286,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<Progress, RunError> {
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 +323,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<std::cell::Cell<bool>> {
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 +387,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<crate::Arg>, 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) {
+4
View File
@@ -22,6 +22,10 @@ craft-vectorcraft-format = { workspace = true }
sha2 = { version = "0.10", default-features = false }
thiserror = { workspace = true }
wasmi = { workspace = true }
# The monotonic clock of run deadlines and the first-view budget: `std::time::Instant` on the
# desktop, the page's `performance.now()` in the web build. It is MIT OR Apache-2.0 and already in
# the build through eframe.
web-time = "1"
[dev-dependencies]
wat = { workspace = true }
+727
View File
@@ -0,0 +1,727 @@
//! Live-effect plug-ins while a document opens: the plug-in effects drawn for its first view share
//! a time budget of [`OPENING_BUDGET_MS`], and a stage that is not run for want of time, or that
//! gives no result, is deferred: it draws its input art and is listed with the reason
//! ([`Deferrals::list`]) until it runs again.
//!
//! One [`Deferrals`] belongs to each open document. Drawing that document on screen runs inside
//! [`Deferrals::view`], which applies its budget and its deferred stages to the plug-in effects
//! evaluated on that thread ([`crate::effect`]):
//!
//! - until [`Deferrals::first_view_done`], every run takes the lesser of its per-call limit and
//! what is left of the budget, no run starts once the budget is spent, and a result that comes
//! back after the budget ran out is not used;
//! - afterwards a deferred stage is not run again while its input and parameters stay the same; a
//! stage whose input or parameters changed runs under the per-call limit;
//! - [`Deferrals::refresh`] runs deferred stages again with a fresh budget.
//!
//! Work outside a view (exports, commands, drawing without a screen) runs every stage under the
//! per-call limit. When such a run gives a result for a stage some document has deferred,
//! [`revision`] changes, so that drawings made with the deferred stage are drawn again.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
/// The time the plug-in effects drawn for a document's first view share, in milliseconds.
pub const OPENING_BUDGET_MS: u64 = 2_000;
/// Why a stage is deferred.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Reason {
/// It was not run, or its result came too late, because the budget was spent.
Budget,
/// It ran to its per-call time limit.
Timeout,
/// Its plug-in isn't installed as a live effect.
Missing,
/// It trapped, panicked, returned art that was refused, or its parameters were refused.
Failed,
/// Safe Mode is on: no plug-in runs.
SafeMode,
}
impl Reason {
/// The reason as `effect.deferredStatus` names it.
pub fn name(self) -> &'static str {
match self {
Reason::Budget => "budget",
Reason::Timeout => "timeout",
Reason::Missing => "missing",
Reason::Failed => "failed",
Reason::SafeMode => "safeMode",
}
}
}
/// Where a plug-in effect sits: the object, the index of the effect in its list, and the fill or
/// stroke whose list that is (`None`: the object's own effects).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Stage {
pub object: u64,
pub effect: usize,
pub item: Option<usize>,
}
/// A deferred stage.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Deferred {
pub stage: Stage,
/// The plug-in's id.
pub plugin: String,
pub reason: Reason,
/// The input and parameters the stage was deferred with (the effect cache's key), 0 when the
/// plug-in was not looked at (Safe Mode, a missing plug-in).
pub(crate) key: u64,
}
/// What [`Deferrals::refresh`] did with the stages it ran again.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Refreshed {
/// Stages that gave a result.
pub refreshed: usize,
/// Stages left deferred for want of time, or with input the refresh did not run.
pub still_deferred: usize,
/// Stages that ran out of their per-call time, failed or had no plug-in again.
pub failed: usize,
}
/// Milliseconds on a monotonic clock, `None` from a clock that cannot be read.
pub type Clock = Arc<dyn Fn() -> Option<u64> + Send + Sync>;
/// Milliseconds since the first reading in this process, from `web_time::Instant`: the system's
/// monotonic clock on the desktop and the page's `performance.now()` in the web build.
fn monotonic() -> Option<u64> {
static START: std::sync::OnceLock<web_time::Instant> = std::sync::OnceLock::new();
let start = START.get_or_init(web_time::Instant::now);
Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX))
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Phase {
/// Runs share a budget that started at `started` (`None`: no run yet). `first`: the first
/// view, which reports what it deferred when it ends.
Budgeted { started: Option<u64>, first: bool },
/// Deferred stages are left as they are while their input stays the same.
Open,
}
struct State {
phase: Phase,
deferred: Vec<Deferred>,
/// How many stages the first view left deferred, until [`Deferrals::take_report`] takes it.
report: Option<usize>,
/// The refresh running, if one is ([`Deferrals::refresh`]).
refresh: Option<Refresh>,
clock: Clock,
}
/// A [`Deferrals::refresh`] while it runs. It applies only to the thread that runs it: drawings
/// of the document on other threads go on as if no refresh ran.
struct Refresh {
thread: std::thread::ThreadId,
/// When its budget started (`None`: no run yet).
started: Option<u64>,
/// The objects whose stages run again; the others' stay deferred.
ids: Vec<u64>,
/// The cache keys whose cached failures have been run again.
retried: Vec<u64>,
/// The stages of `ids` that were drawn.
visited: Vec<Stage>,
/// The stage inputs of `ids` that ran, or were taken from the cache, or were deferred again.
touched: Vec<(Stage, u64)>,
}
impl State {
/// The refresh running on this thread.
fn refresh_here(&mut self) -> Option<&mut Refresh> {
let here = std::thread::current().id();
self.refresh.as_mut().filter(|r| r.thread == here)
}
/// The phase that applies on this thread: a refresh running here has a budget of its own.
fn phase_here(&mut self) -> Phase {
match self.refresh_here() {
Some(r) => Phase::Budgeted { started: r.started, first: false },
None => self.phase,
}
}
/// Is a refresh running here, and does it run the stages of `object`?
fn refreshes(&mut self, object: u64) -> Option<bool> {
self.refresh_here().map(|r| r.ids.contains(&object))
}
}
impl Drop for State {
fn drop(&mut self) {
for d in &self.deferred {
unmark(d.key);
}
}
}
/// The deferred stages and the opening budget of one open document. Clones share them.
#[derive(Clone)]
pub struct Deferrals(Arc<Mutex<State>>);
impl Default for Deferrals {
fn default() -> Self {
Self::with_clock(Arc::new(monotonic))
}
}
impl std::fmt::Debug for Deferrals {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Deferrals").field("deferred", &self.list()).finish()
}
}
/// What a budgeted run may take.
pub(crate) enum Allowance {
/// No budget applies: the per-call limit.
Unlimited,
/// At most this many milliseconds, which end the budget at `until`.
Until { ms: u64, until: u64 },
/// The budget is spent: the stage is deferred without running.
Spent,
}
impl Deferrals {
/// A document's deferrals before its first view, timed by the monotonic clock.
pub fn new() -> Self {
Self::default()
}
/// [`Deferrals::new`] timed by `clock` (tests give a clock of their own).
pub fn with_clock(clock: Clock) -> Self {
Self(Arc::new(Mutex::new(State {
phase: Phase::Budgeted { started: None, first: true },
deferred: Vec::new(),
report: None,
refresh: None,
clock,
})))
}
fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The deferred stages, by object and effect, each once (a stage drawn with several inputs,
/// such as the paths of a group, is listed with the first one's reason).
pub fn list(&self) -> Vec<Deferred> {
let mut out = self.state().deferred.clone();
out.sort_by_key(|d| (d.stage.object, d.stage.item, d.stage.effect));
out.dedup_by_key(|d| d.stage);
out
}
/// Is the first view still being drawn ([`Deferrals::first_view_done`] not called yet)?
pub fn opening(&self) -> bool {
matches!(self.state().phase, Phase::Budgeted { first: true, .. })
}
/// Runs `f` with these deferrals applied to the plug-in effects evaluated on this thread.
pub fn view<T>(&self, f: impl FnOnce() -> T) -> T {
let previous = VIEW.with(|v| v.borrow_mut().replace(self.clone()));
let _ = PASSES.try_with(|p| p.borrow_mut().push(Pass::default()));
let _restore = Restore { previous, current: self.clone() };
f()
}
/// One drawing inside [`Deferrals::view`] has ended: a stage that ran after the first view,
/// whether it gave a result or was deferred again, keeps only the records of the inputs that
/// drawing left deferred. The records of its other inputs were made with input it no longer
/// has.
fn settle(&self, pass: Pass) {
if pass.ran.is_empty() {
return;
}
self.state().deferred.retain(|d| {
let keep = !pass.ran.contains(&d.stage) || pass.seen.contains(&(d.stage, d.key));
if !keep {
unmark(d.key);
}
keep
});
}
/// The first view has been drawn: later drawings leave deferred stages deferred, and
/// [`Deferrals::take_report`] gives how many there are. Does nothing after the first call.
pub fn first_view_done(&self) {
let mut s = self.state();
if let Phase::Budgeted { first: true, .. } = s.phase {
s.phase = Phase::Open;
s.report = Some(s.stages().len());
}
}
/// How many stages the first view left deferred, once, after it was drawn.
pub fn take_report(&self) -> Option<usize> {
self.state().report.take()
}
/// Runs the deferred stages of the objects in `objects` (all of them when `None`) again with a
/// fresh budget: `run` is given those objects, draws them inside [`Deferrals::view`] on this
/// thread, and returns the objects it drew whole, every part of them reached. A stage the
/// drawing does not reach (that of a hidden object, say) stays deferred as it was, and so does
/// a stage input it does not reach, unless the object was drawn whole: the input is then one
/// the stage no longer has. The refresh and its budget apply only to this thread.
pub fn refresh(&self, objects: Option<&[u64]>, run: impl FnOnce(&[u64]) -> Vec<u64>) -> Refreshed {
let (targets, ids) = {
let mut s = self.state();
let mut targets: Vec<Stage> = s.deferred.iter().map(|d| d.stage).filter(|t| objects.is_none_or(|o| o.contains(&t.object))).collect();
targets.sort_by_key(|t| (t.object, t.item, t.effect));
targets.dedup();
let mut ids: Vec<u64> = targets.iter().map(|t| t.object).collect();
ids.dedup();
let thread = std::thread::current().id();
let (retried, visited, touched) = (Vec::new(), Vec::new(), Vec::new());
s.refresh = Some(Refresh { thread, started: None, ids: ids.clone(), retried, visited, touched });
(targets, ids)
};
// The refresh ends when `run` returns, also when it unwinds.
struct Reopen<'a>(&'a Deferrals);
impl Drop for Reopen<'_> {
fn drop(&mut self) {
self.0.state().refresh = None;
}
}
let reopen = Reopen(self);
let whole = run(&ids);
let mut s = self.state();
let touched = match s.refresh.take() {
Some(r) => {
s.deferred.retain(|d| {
let gone = r.visited.contains(&d.stage) && whole.contains(&d.stage.object) && !r.touched.contains(&(d.stage, d.key));
if gone {
unmark(d.key);
}
!gone
});
r.touched
}
None => Vec::new(),
};
drop(s);
drop(reopen);
// A stage fails again when an input it ran with this time is deferred for a reason other
// than time; one with records left that this refresh did not run is still deferred.
let s = self.state();
let mut out = Refreshed::default();
for t in &targets {
let mut left = s.deferred.iter().filter(|d| d.stage == *t).peekable();
if left.peek().is_none() {
out.refreshed += 1;
} else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(d.stage, d.key))) {
out.failed += 1;
} else {
out.still_deferred += 1;
}
}
drop(s);
if out.refreshed > 0 {
REVISION.fetch_add(1, Ordering::Relaxed);
}
out
}
/// Records `stage` with cache key `key` (of plug-in `plugin`) as deferred for `reason`, in
/// place of what was recorded for that stage and key. After the first view, when the drawing
/// ends, the stage's records of inputs that drawing did not leave deferred are dropped
/// ([`Deferrals::settle`]).
pub(crate) fn defer(&self, stage: Stage, plugin: &str, reason: Reason, key: u64) {
seen(stage, key);
let mut s = self.state();
s.remove(stage, key);
mark(key);
s.deferred.push(Deferred { stage, plugin: plugin.to_string(), reason, key });
s.ran_here(stage, key);
}
/// `stage` gave a result for cache key `key`, from a run or from the cache: it is no longer
/// deferred with that input. After the first view, when the drawing ends, the stage's records
/// of inputs that drawing did not leave deferred are dropped as well ([`Deferrals::settle`]).
pub(crate) fn ran(&self, stage: Stage, key: u64) {
let mut s = self.state();
s.remove(stage, key);
s.ran_here(stage, key);
}
/// Should `stage`, with cache key `key`, be left deferred without running? After the first
/// view, a stage deferred with the same input and parameters is, and so is one of an object a
/// refresh does not run.
pub(crate) fn holds(&self, stage: Stage, key: u64) -> bool {
let mut s = self.state();
let settled = s.phase_here() == Phase::Open || s.refreshes(stage.object) == Some(false);
let held = settled && s.deferred.iter().any(|d| d.stage == stage && d.key == key);
drop(s);
if held {
seen(stage, key);
}
held
}
/// `stage` is being drawn: a refresh running here that runs its object's stages notes that it
/// reached the stage.
pub(crate) fn visit(&self, stage: Stage) {
let mut s = self.state();
if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.visited.contains(&stage)) {
r.visited.push(stage);
}
}
/// Drops the records of the stages for which `keep` (given the stage and the plug-in id) is
/// false, such as those of effects or objects removed from the document.
pub fn retain(&self, keep: impl Fn(Stage, &str) -> bool) {
self.state().deferred.retain(|d| {
let k = keep(d.stage, &d.plugin);
if !k {
unmark(d.key);
}
k
});
}
/// Should the failure cached for `stage` with cache key `key` be run again? Yes once per key
/// while a refresh running here runs the stages of `stage`'s object.
pub(crate) fn retries(&self, stage: Stage, key: u64) -> bool {
let mut s = self.state();
let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.retried.contains(&key)) else { return false };
r.retried.push(key);
true
}
/// What a run starting now may take of the budget: the budget starts with the first run.
/// Without a clock, the first view runs nothing and a refresh runs every stage under its
/// per-call limit.
pub(crate) fn allowance(&self) -> Allowance {
let mut s = self.state();
let Phase::Budgeted { started, first } = s.phase_here() else { return Allowance::Unlimited };
let Some(now) = (s.clock)() else { return if first { Allowance::Spent } else { Allowance::Unlimited } };
let start = started.unwrap_or(now);
match s.refresh_here() {
Some(r) => r.started = Some(start),
None => s.phase = Phase::Budgeted { started: Some(start), first },
}
let until = start.saturating_add(OPENING_BUDGET_MS);
match until.checked_sub(now).filter(|ms| *ms > 0) {
Some(ms) => Allowance::Until { ms, until },
None => Allowance::Spent,
}
}
/// Has the clock passed `until` (a result that came back now is too late)? Without a clock,
/// yes.
pub(crate) fn passed(&self, until: u64) -> bool {
let clock = self.state().clock.clone();
clock().is_none_or(|now| now > until)
}
}
impl State {
/// Removes what is recorded for `stage` with cache key `key`. A record without a key (the
/// plug-in was not looked at) stands for any input, so it goes whatever `key` is, and `key` 0
/// removes every record of the stage.
fn remove(&mut self, stage: Stage, key: u64) {
self.deferred.retain(|d| {
let keep = d.stage != stage || (d.key != key && d.key != 0 && key != 0);
if !keep {
unmark(d.key);
}
keep
});
}
/// `stage` was run with cache key `key`, or taken from the cache, by the drawing on this
/// thread: a refresh running here notes the input ([`Deferrals::refresh`]), and after the first
/// view [`Deferrals::settle`] looks at the stage when that drawing ends.
fn ran_here(&mut self, stage: Stage, key: u64) {
if let Some(r) = self.refresh_here().filter(|r| r.ids.contains(&stage.object)) {
r.touched.push((stage, key));
}
if self.phase_here() == Phase::Open {
let _ = PASSES.try_with(|p| {
if let Some(pass) = p.borrow_mut().last_mut().filter(|pass| !pass.ran.contains(&stage)) {
pass.ran.push(stage);
}
});
}
}
/// The deferred stages, each once.
fn stages(&self) -> Vec<Stage> {
let mut out: Vec<Stage> = self.deferred.iter().map(|d| d.stage).collect();
out.sort_by_key(|t| (t.object, t.item, t.effect));
out.dedup();
out
}
}
thread_local! {
static VIEW: RefCell<Option<Deferrals>> = const { RefCell::new(None) };
/// One entry for each [`Deferrals::view`] running on this thread, innermost last.
static PASSES: RefCell<Vec<Pass>> = const { RefCell::new(Vec::new()) };
}
/// What one drawing inside a view did: the stages that ran after the first view (with a result or
/// deferred again), and the stage inputs it left deferred.
#[derive(Default)]
struct Pass {
ran: Vec<Stage>,
seen: Vec<(Stage, u64)>,
}
/// The drawing on this thread left `stage` deferred with cache key `key`.
fn seen(stage: Stage, key: u64) {
let _ = PASSES.try_with(|p| {
if let Some(pass) = p.borrow_mut().last_mut() {
pass.seen.push((stage, key));
}
});
}
/// Ends the drawing and puts the previous view back, also when the work inside unwinds.
struct Restore {
previous: Option<Deferrals>,
current: Deferrals,
}
impl Drop for Restore {
fn drop(&mut self) {
if let Some(pass) = PASSES.try_with(|p| p.borrow_mut().pop()).ok().flatten() {
self.current.settle(pass);
}
let previous = self.previous.take();
let _ = VIEW.try_with(|v| *v.borrow_mut() = previous);
}
}
/// The deferrals of the view being drawn on this thread ([`Deferrals::view`]).
pub fn current() -> Option<Deferrals> {
VIEW.try_with(|v| v.borrow().clone()).ok().flatten()
}
/// Changes when deferred stages give results: [`Deferrals::refresh`] ran some, or a run outside a
/// view gave a result for a stage some document has deferred.
pub fn revision() -> u64 {
REVISION.load(Ordering::Relaxed)
}
static REVISION: AtomicU64 = AtomicU64::new(0);
/// The cache keys some document's deferred stages were deferred with, and how many stages hold each.
static MARKED: Mutex<Option<HashMap<u64, usize>>> = Mutex::new(None);
fn mark(key: u64) {
if key != 0 {
*MARKED.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).entry(key).or_default() += 1;
}
}
fn unmark(key: u64) {
let mut marked = MARKED.lock().unwrap_or_else(|e| e.into_inner());
let Some(m) = marked.as_mut() else { return };
if let Some(n) = m.get_mut(&key) {
*n -= 1;
if *n == 0 {
m.remove(&key);
}
}
}
/// A run gave a result for cache key `key`: when some document deferred a stage with that key,
/// [`revision`] changes.
pub(crate) fn resolved(key: u64) {
let marked = MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&key));
if marked {
REVISION.fetch_add(1, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A clock that reads `step` milliseconds later each time it is read.
fn stepping(step: u64) -> Clock {
let t = Arc::new(AtomicU64::new(0));
Arc::new(move || Some(t.fetch_add(step, Ordering::Relaxed)))
}
#[test]
fn the_budget_starts_with_the_first_run_and_ends_after_it() {
let d = Deferrals::with_clock(stepping(700));
assert!(matches!(d.allowance(), Allowance::Until { ms: 2_000, until: 2_000 }));
assert!(matches!(d.allowance(), Allowance::Until { ms: 1_300, until: 2_000 }));
assert!(matches!(d.allowance(), Allowance::Until { ms: 600, until: 2_000 }));
assert!(matches!(d.allowance(), Allowance::Spent));
d.first_view_done();
assert!(matches!(d.allowance(), Allowance::Unlimited));
}
#[test]
fn without_a_clock_the_first_view_runs_nothing_and_a_refresh_everything() {
let d = Deferrals::with_clock(Arc::new(|| None));
assert!(matches!(d.allowance(), Allowance::Spent));
assert!(d.passed(u64::MAX));
d.first_view_done();
d.defer(Stage::default(), "a", Reason::Budget, 31);
d.refresh(None, |_| {
assert!(matches!(d.allowance(), Allowance::Unlimited));
vec![]
});
}
/// The default clock, which the web build also reads, gives the first view its budget.
#[test]
fn the_default_clock_gives_the_first_view_a_budget() {
let d = Deferrals::new();
assert!(matches!(d.allowance(), Allowance::Until { ms: OPENING_BUDGET_MS, .. }));
assert!(!d.passed(u64::MAX));
}
/// A stage drawn with several inputs (the paths of a group) holds one record for each, and is
/// listed and counted once.
#[test]
fn a_stage_is_listed_and_counted_once() {
let d = Deferrals::with_clock(stepping(1));
let s = Stage { object: 7, effect: 1, item: None };
d.defer(s, "a", Reason::Budget, 11);
d.defer(s, "a", Reason::Budget, 12);
d.defer(s, "a", Reason::Timeout, 12);
d.defer(Stage { item: Some(0), ..s }, "a", Reason::Failed, 13);
assert_eq!(d.list().len(), 2);
assert!(!d.holds(s, 12), "the first view runs deferred stages");
d.first_view_done();
assert!(d.holds(s, 11) && d.holds(s, 12));
assert!(!d.holds(s, 14), "other input");
assert_eq!(d.take_report(), Some(2));
assert_eq!(d.take_report(), None);
d.first_view_done();
assert_eq!(d.take_report(), None, "the first view ends once");
d.ran(s, 11);
assert_eq!(d.list().len(), 2, "the stage is still deferred with its other input");
d.ran(s, 12);
assert_eq!(d.list().len(), 1);
d.defer(s, "a", Reason::Missing, 0);
d.ran(s, 15);
assert_eq!(d.list().len(), 1, "a record without a key goes with any result");
}
/// A refresh runs the deferred stages of the objects asked for with a fresh budget, and counts
/// what became of them; the stages its drawing did not reach stay as they were. Another
/// thread draws the document as if no refresh ran.
#[test]
fn a_refresh_counts_what_became_of_each_stage() {
let d = Deferrals::with_clock(stepping(1));
d.first_view_done();
let st = |object, effect| Stage { object, effect, item: None };
let stages = [
(st(1, 0), Reason::Budget),
(st(1, 1), Reason::Budget),
(st(1, 2), Reason::Budget),
(st(1, 3), Reason::Timeout),
(st(2, 0), Reason::Budget),
(st(3, 0), Reason::Failed),
];
for (stage, reason) in stages {
d.defer(stage, "a", reason, 40 + stage.object * 4 + stage.effect as u64);
}
let seen = std::cell::RefCell::new(vec![]);
let counts = d.refresh(Some(&[1, 3, 9]), |ids| {
seen.borrow_mut().extend_from_slice(ids);
assert!(matches!(d.allowance(), Allowance::Until { .. }), "a fresh budget");
assert!(d.holds(st(2, 0), 48), "an object not asked for stays deferred");
d.visit(st(2, 0));
d.visit(st(1, 0));
d.visit(st(1, 1));
d.defer(st(1, 1), "a", Reason::Budget, 45);
std::thread::scope(|scope| {
let other = scope.spawn(|| (matches!(d.allowance(), Allowance::Unlimited), d.holds(st(1, 2), 46), d.retries(st(3, 0), 52)));
assert_eq!(other.join().unwrap(), (true, true, false), "another thread");
});
d.visit(st(1, 1));
assert_eq!(d.list().iter().filter(|x| x.stage == st(1, 1)).count(), 1, "a stage is reached once");
d.visit(st(3, 0));
d.defer(st(3, 0), "a", Reason::Failed, 52);
ids.to_vec()
});
assert_eq!(*seen.borrow(), [1, 3]);
// st(1, 3) timed out before and was not reached: still deferred, not failed again.
assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 3, failed: 1 });
assert!(matches!(d.allowance(), Allowance::Unlimited), "the budget ends with the refresh");
let left = d.list().iter().map(|x| x.stage).collect::<Vec<_>>();
assert_eq!(left, [st(1, 1), st(1, 2), st(1, 3), st(2, 0), st(3, 0)]);
// An object the drawing did not reach whole keeps the inputs it did not reach: a reached
// stage of it gives a result only for the inputs that ran.
d.defer(st(1, 1), "a", Reason::Budget, 70);
let counts = d.refresh(Some(&[1]), |_| {
d.visit(st(1, 1));
d.ran(st(1, 1), 45);
vec![]
});
assert_eq!(counts, Refreshed { refreshed: 0, still_deferred: 3, failed: 0 });
let keys = |stage| d.state().deferred.iter().filter(|x| x.stage == stage).map(|x| x.key).collect::<Vec<_>>();
assert_eq!(keys(st(1, 1)), [70]);
let counts = d.refresh(Some(&[1]), |_| {
d.visit(st(1, 1));
vec![1]
});
assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 2, failed: 0 }, "drawn whole: an input it no longer has");
assert_eq!(keys(st(1, 1)), Vec::<u64>::new());
d.retain(|stage, _| stage.object != 1);
assert_eq!(d.list().iter().map(|x| x.stage).collect::<Vec<_>>(), [st(2, 0), st(3, 0)]);
}
/// After the first view, a stage drawn with several inputs (the paths of a group) whose one
/// changed input runs keeps the records of the inputs the same drawing left deferred, and drops
/// the record of the input it no longer has.
#[test]
fn a_stage_that_runs_keeps_the_inputs_its_drawing_left_deferred() {
let d = Deferrals::with_clock(stepping(1));
let s = Stage { object: 4, effect: 0, item: None };
let other = Stage { object: 5, ..s };
for key in [61, 62] {
d.defer(s, "a", Reason::Budget, key);
}
d.defer(other, "a", Reason::Budget, 63);
d.first_view_done();
d.view(|| {
assert!(d.holds(s, 61), "an unchanged path");
assert!(!d.holds(s, 64), "the changed path");
d.ran(s, 64);
});
let keys = d.state().deferred.iter().map(|x| (x.stage.object, x.key)).collect::<Vec<_>>();
assert_eq!(keys, [(4, 61), (5, 63)]);
// A changed input that fails again replaces the record of the input it no longer has.
d.view(|| {
assert!(!d.holds(other, 65));
d.defer(other, "a", Reason::Failed, 65);
});
let left = d.state().deferred.iter().map(|x| (x.stage.object, x.key, x.reason)).collect::<Vec<_>>();
assert_eq!(left, [(4, 61, Reason::Budget), (5, 65, Reason::Failed)]);
}
#[test]
fn a_result_for_a_deferred_key_changes_the_revision() {
const KEY: u64 = 0xdef0_0001;
let marked = || MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&KEY));
let d = Deferrals::with_clock(stepping(1));
assert!(!marked());
d.defer(Stage::default(), "a", Reason::Budget, KEY);
let other = d.clone();
other.defer(Stage { object: 2, ..Stage::default() }, "a", Reason::Budget, KEY);
assert!(marked());
let before = revision();
resolved(KEY);
assert!(revision() > before);
other.ran(Stage::default(), KEY);
assert!(marked(), "another stage holds the key");
drop((d, other));
assert!(!marked(), "the document closed");
}
}
+256 -23
View File
@@ -12,7 +12,8 @@
//! plug-in installation, parameters, input geometry and paints: the plug-in runs again only when
//! one of them changes. Failures are cached too (the geometry is then drawn unchanged) and
//! reported by [`last_error`]. The swatches and patterns a painted result names are checked
//! against the document each time it is used.
//! against the document each time it is used. While a document is drawn on screen, its first view
//! shares a time budget and stages without a result are deferred ([`crate::deferred`]).
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
@@ -22,6 +23,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, AppearanceItem, Document};
use vectorcraft_geom::{FillRule, PathData, Rect};
use crate::deferred::{Allowance, Reason, Stage};
use crate::objects::{decode, output_geometry, paint_lists};
use crate::painted::{self, Piece, Scope};
use crate::{Error, Kind, Limits, Result, registry};
@@ -69,6 +71,9 @@ pub struct Paints<'a> {
/// The index in `appearance.items` of the fill or stroke whose own effects hold this one;
/// `None`: one of the object's effects.
pub item: Option<usize>,
/// The object and the effect's place in its list, which a deferred stage is listed by
/// ([`crate::deferred`]); `None`: not known, listed as object 0.
pub at: Option<Stage>,
}
/// [`Paints`] as the plug-in is given them: the input object's `fills` and `strokes`, and
@@ -151,7 +156,8 @@ impl Out {
#[derive(Default)]
struct Cache {
entries: HashMap<u64, (Key, Option<Out>)>,
/// Each result, or why the run gave none ([`Reason::Timeout`] or [`Reason::Failed`]).
entries: HashMap<u64, (Key, std::result::Result<Out, Reason>)>,
/// The anchors of the entries' inputs and results ([`CACHE_ANCHORS`]).
anchors: usize,
}
@@ -294,9 +300,10 @@ pub fn paints_output(id: &str) -> bool {
/// For an effect that returns painted pieces, the geometry is every piece's path in order, and
/// `None` when the swatches and patterns they name are refused as [`apply_painted`] refuses them.
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints, doc: Option<&Document>) -> Option<PathData> {
let at = paints.at;
let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?;
if let Out::Pieces(pieces) = &out {
references_found(id, &plugin, pieces, doc)?;
references_found(id, &plugin, pieces, doc, at)?;
}
Some(out.geometry())
}
@@ -318,9 +325,10 @@ pub fn apply_painted(
paints: Paints,
doc: Option<&Document>,
) -> Option<Arc<Vec<Piece>>> {
let at = paints.at;
let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?;
let Out::Pieces(pieces) = out else { return None };
references_found(id, &plugin, &pieces, doc)?;
references_found(id, &plugin, &pieces, doc, at)?;
Some(match doc {
Some(d) => painted::with_swatch_colors(pieces, d),
None => pieces,
@@ -329,11 +337,17 @@ pub fn apply_painted(
/// `Some` when every swatch and pattern `pieces` name is in `doc` ([`painted::check_references`]),
/// recording the outcome for [`last_error`] when there is a document. The document's swatches and
/// patterns can change while a result stays cached, so each use checks them.
fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>) -> Option<()> {
/// patterns can change while a result stays cached, so each use checks them. Inside a document's
/// view, a refusal defers stage `at` as failed.
fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>, at: Option<Stage>) -> Option<()> {
let checked = painted::check_references(pieces, doc);
if doc.is_some() {
note(id, plugin, Origin::References, checked.as_ref().map(|_| ()));
if checked.is_err()
&& let Some(v) = crate::deferred::current()
{
v.defer(at.unwrap_or_default(), id, Reason::Failed, 0);
}
}
checked.ok()
}
@@ -341,12 +355,36 @@ fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Opt
/// Runs plug-in `id` as [`apply`] says, or takes its result from the cache: the plug-in and what
/// it gave. The launch marker names the plug-in during its first run in this process, and until
/// every run that started while it ran has ended too ([`crate::safe_mode::loading`]).
///
/// Inside a document's view ([`crate::deferred`]), a stage without a result is recorded as
/// deferred with the reason, and one with a result is no longer deferred. During the view's
/// budget a run takes at most what is left of it, none starts once it is spent, and a result
/// that comes back after it ran out is not used; neither is cached or reported by
/// [`last_error`]. After the first view, a stage deferred with the same input is not run again.
fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<(Arc<crate::Plugin>, Out)> {
let view = crate::deferred::current();
let stage = paints.at.unwrap_or_default();
if let Some(v) = &view {
v.visit(stage);
}
let defer = |reason, key| {
if let Some(v) = &view {
v.defer(stage, id, reason, key);
}
};
if crate::safe_mode::active() {
defer(Reason::SafeMode, 0);
return None;
}
let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?;
let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?);
let Some(plugin) = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect) else {
defer(Reason::Missing, 0);
return None;
};
let Ok(resolved) = plugin.manifest().resolve_params(params) else {
defer(Reason::Failed, 0);
return None;
};
let resolved = Value::Object(resolved);
let given = paints.given();
let key = Key {
serial: plugin.serial(),
@@ -357,14 +395,44 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR
path: path.clone(),
};
let h = key.hash64();
if let Some((k, out)) = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|c| c.entries.get(&h))
&& *k == key
{
return out.clone().map(|o| (plugin, o));
let cached = CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.and_then(|c| c.entries.get(&h))
.filter(|(k, _)| *k == key)
.map(|(_, out)| out.clone());
match cached {
// A result from the cache counts as a run that gave it.
Some(Ok(o)) => {
if let Some(v) = &view {
v.ran(stage, h);
}
return Some((plugin, o));
}
// A refresh runs a stage that failed again.
Some(Err(reason)) if !view.as_ref().is_some_and(|v| v.retries(stage, h)) => {
defer(reason, h);
return None;
}
_ => {}
}
let allowance = match &view {
Some(v) if v.holds(stage, h) => return None,
Some(v) => v.allowance(),
None => Allowance::Unlimited,
};
let (wall_time_ms, until) = match allowance {
Allowance::Unlimited => (WALL_TIME_MS, None),
Allowance::Until { ms, until } => (ms.min(WALL_TIME_MS), Some(until)),
Allowance::Spent => {
defer(Reason::Budget, h);
return None;
}
};
let scope = paints.scope();
let guarded = begin_run(&plugin);
let r = run(&plugin, resolved, path, bounds, rule, given, scope);
let r = run(&plugin, resolved, path, (bounds, rule), given, scope, wall_time_ms);
let cancelled = matches!(r, Err(Error::Cancelled));
if guarded {
end_run(plugin.serial(), cancelled);
@@ -373,11 +441,28 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR
if cancelled {
return None;
}
let timed_out = matches!(&r, Err(Error::Limit(l)) if l == crate::host::TIME_BUDGET);
// A run the budget cut short, or whose result came after the budget ran out, says nothing
// about the plug-in either: the stage waits for a later run.
let late = until.is_some_and(|u| view.as_ref().is_some_and(|v| v.passed(u)));
if (timed_out && wall_time_ms < WALL_TIME_MS) || late {
defer(Reason::Budget, h);
return None;
}
note(id, &plugin, Origin::Run, r.as_ref().map(|_| ()));
let out = r.ok();
let size = weight(&key, out.as_ref());
let out = r.map_err(|_| if timed_out { Reason::Timeout } else { Reason::Failed });
match &out {
Ok(_) => {
if let Some(v) = &view {
v.ran(stage, h);
}
crate::deferred::resolved(h);
}
Err(reason) => defer(*reason, h),
}
let size = weight(&key, out.as_ref().ok());
if size > CACHE_ANCHORS / 4 {
return out.map(|o| (plugin, o));
return out.ok().map(|o| (plugin, o));
}
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let cache = cache.get_or_insert_with(Cache::default);
@@ -385,15 +470,24 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR
*cache = Cache::default();
}
if let Some((old, old_out)) = cache.entries.insert(h, (key, out.clone())) {
cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref()));
cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref().ok()));
}
cache.anchors += size;
out.map(|o| (plugin, o))
out.ok().map(|o| (plugin, o))
}
/// One run of `plugin` on `path`, with the object's paints as `given`: geometry, or for an effect
/// that declares `paintOutput` its pieces, checked for an effect in `scope`.
fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given, scope: Scope) -> Result<Out> {
/// One run of `plugin` on `path` (reference box and fill rule `(bounds, rule)`), with the object's
/// paints as `given`, stopped after `wall_time_ms`: geometry, or for an effect that declares
/// `paintOutput` its pieces, checked for an effect in `scope`.
fn run(
plugin: &crate::Plugin,
mut params: Value,
path: &PathData,
(bounds, rule): (Rect, FillRule),
given: Given,
scope: Scope,
wall_time_ms: u64,
) -> Result<Out> {
let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]);
params["_context"] = json!({"mode": "effect", "bounds": b, "item": given.item});
let rule_name = match rule {
@@ -404,7 +498,9 @@ fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect,
json!({"objects": [{"type": "path", "path": path, "fillRule": rule_name, "bounds": b, "fills": given.fills, "strokes": given.strokes}]});
let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(&params));
let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) };
let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() };
let limits = Limits { wall_time_ms, ..plugin.limits().clone() };
#[cfg(test)]
tests::RUNS_STARTED.with(|n| n.set(n.get() + 1));
let output = plugin.run_limited(&input, &params, &limits)?;
if plugin.manifest().paint_output {
return Ok(Out::Pieces(Arc::new(painted::decode(&output, scope, rule)?)));
@@ -420,6 +516,11 @@ mod tests {
use super::*;
thread_local! {
/// How many plug-in runs this thread started ([`run`]).
pub(super) static RUNS_STARTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Fills and strokes come in paint order, and the item an effect sits on is counted among the
/// fills or among the strokes.
#[test]
@@ -427,7 +528,7 @@ mod tests {
let fill = |c| AppearanceItem::Fill(FillLayer::new(Paint::solid(c)));
let stroke = |w| AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), w));
let ap = Appearance { items: vec![fill(Color::WHITE), stroke(2.0), fill(Color::BLACK), stroke(5.0)], ..Appearance::default() };
let given = |item| Paints { appearance: Some(&ap), item }.given();
let given = |item| Paints { appearance: Some(&ap), item, at: None }.given();
let g = given(None);
assert_eq!((g.fills.len(), g.strokes.len(), &g.item), (2, 2, &json!("object")));
assert_eq!(g.strokes.iter().map(|s| s["width"].clone()).collect::<Vec<_>>(), [json!(2.0), json!(5.0)]);
@@ -494,9 +595,141 @@ mod tests {
assert!(last_error(ID).unwrap().contains("trapped"));
assert!(draw(&short).is_none(), "from the cache, refused again");
assert!(last_error(ID).unwrap().contains("swatch"));
// Inside a document's view, the refused stage is deferred as failed, also from the cache.
let view = crate::deferred::Deferrals::with_clock(Arc::new(|| Some(0)));
let at = Stage { object: 3, effect: 1, item: None };
let in_view = || {
view.view(|| {
apply_painted(
ID,
&json!({}),
&short,
Rect::new(0.0, 0.0, 1.0, 1.0),
FillRule::NonZero,
Paints { at: Some(at), ..Paints::default() },
Some(&doc),
)
})
};
assert!(in_view().is_none());
let listed = || view.list().iter().map(|d| (d.stage, d.reason)).collect::<Vec<_>>();
assert_eq!(listed(), [(at, Reason::Failed)]);
view.first_view_done();
assert!(in_view().is_none());
assert_eq!(listed(), [(at, Reason::Failed)], "a later drawing leaves it listed");
registry::remove(ID);
}
/// A clock that reads `step` milliseconds later each time it is read.
fn stepping(step: u64) -> crate::deferred::Clock {
let t = Arc::new(std::sync::atomic::AtomicU64::new(0));
Arc::new(move || Some(t.fetch_add(step, std::sync::atomic::Ordering::Relaxed)))
}
/// A line of `n` points: a distinct input for each `n`.
fn line(n: usize) -> PathData {
PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::<Vec<_>>(), false))
}
/// Plug-in `id` drawn on `path` as effect `effect` of object 1: whether it gave a result.
fn drawn(id: &str, path: &PathData, effect: usize) -> bool {
let at = Some(Stage { object: 1, effect, item: None });
apply(id, &json!({}), path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints { at, ..Paints::default() }, None).is_some()
}
/// The effects of a first view share its budget: once it is spent no run starts, and a result
/// that comes back after it ran out is not used. Later drawings leave the deferred stages
/// deferred; a drawing outside the view (an export) runs them, and the view then uses the
/// result.
#[test]
fn the_first_view_shares_the_budget_and_defers_the_rest() {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::safe_mode::set_active(None);
const ID: &str = "test.effect.budget";
let src = crate::wat::returning(&crate::wat::manifest(ID, "effect", "{}"), r#"{"objects":[]}"#);
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
// Each run takes 900 ms by this clock (it is read before and after each run).
let view = crate::deferred::Deferrals::with_clock(stepping(450));
let ran = view.view(|| (0..4).map(|i| drawn(ID, &line(i + 2), i)).collect::<Vec<_>>());
assert_eq!(ran, [true, true, false, false], "the third result comes back at 2,250 ms, the fourth doesn't start");
let listed = view.list().iter().map(|d| (d.stage.effect, d.reason, d.plugin.clone())).collect::<Vec<_>>();
assert_eq!(listed, [(2, Reason::Budget, ID.to_string()), (3, Reason::Budget, ID.to_string())]);
view.first_view_done();
assert_eq!(view.take_report(), Some(2));
assert!(!view.view(|| drawn(ID, &line(4), 2)), "a later drawing leaves it deferred");
assert!(view.view(|| drawn(ID, &line(9), 3)), "an edit to its input runs it");
assert_eq!(view.list().len(), 1);
assert!(drawn(ID, &line(4), 2), "outside the view it runs");
assert_eq!(view.list().len(), 1, "until the view draws it again");
assert!(view.view(|| drawn(ID, &line(9), 2)), "an edit to an input whose result is cached");
assert!(view.list().is_empty(), "the record of the input it no longer has goes");
assert!(view.view(|| drawn(ID, &line(4), 2)), "from the cache");
assert!(view.list().is_empty());
registry::remove(ID);
}
/// A stage that fails, or whose plug-in is missing, is deferred with that reason, from a run or
/// from the cache; a failure is cached and reported as before.
#[test]
fn failed_and_missing_stages_are_deferred_with_their_reason() {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::safe_mode::set_active(None);
const ID: &str = "test.effect.deferfailed";
// Runs to its instruction budget.
let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 1_000_000_000, r#"{"objects":[]}"#);
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
let view = crate::deferred::Deferrals::with_clock(stepping(1));
assert!(!view.view(|| drawn(ID, &line(2), 0)));
assert!(last_error(ID).is_some());
assert!(!view.view(|| drawn("test.effect.notinstalled", &line(2), 1)));
let reasons = |v: &crate::deferred::Deferrals| v.list().iter().map(|d| d.reason).collect::<Vec<_>>();
assert_eq!(reasons(&view), [Reason::Failed, Reason::Missing]);
let other = crate::deferred::Deferrals::with_clock(stepping(1));
assert!(!other.view(|| drawn(ID, &line(2), 0)));
assert_eq!(reasons(&other), [Reason::Failed], "from the cache, in another document");
// A refresh runs a stage whose failure is cached again, once, and later drawings take the
// failure from the cache.
other.first_view_done();
let runs = || RUNS_STARTED.with(std::cell::Cell::get);
let before = runs();
let counts = other.refresh(None, |_| {
other.view(|| (drawn(ID, &line(2), 0), drawn(ID, &line(2), 0)));
vec![]
});
assert_eq!((counts.failed, runs() - before), (1, 1));
assert!(!other.view(|| drawn(ID, &line(2), 0)));
assert_eq!((reasons(&other), runs() - before), (vec![Reason::Failed], 1));
registry::remove(ID);
}
/// A run the budget cuts short is deferred for want of time: it is not cached, and the plug-in
/// reports no error.
#[test]
fn a_run_the_budget_cuts_short_is_deferred_for_time() {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::safe_mode::set_active(None);
const ID: &str = "test.effect.cutshort";
const FAST: &str = "test.effect.cutshort.fast";
let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 10_000_000, r#"{"objects":[]}"#);
let limits = Limits { fuel_base: 1_000_000_000, ..Limits::default() };
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), limits).unwrap());
let fast = crate::wat::returning(&crate::wat::manifest(FAST, "effect", "{}"), r#"{"objects":[]}"#);
registry::install(crate::Plugin::load(&wat::parse_str(&fast).unwrap(), Limits::default()).unwrap());
// The first run starts the budget at 0 ms and ends at 1,999: the next has 1 ms left, and
// ten million steps take longer.
let reads = Arc::new(std::sync::atomic::AtomicU64::new(0));
let clock: crate::deferred::Clock =
Arc::new(move || Some(if reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed) == 0 { 0 } else { 1_999 }));
let view = crate::deferred::Deferrals::with_clock(clock);
assert!(view.view(|| drawn(FAST, &line(2), 0)));
assert!(!view.view(|| drawn(ID, &line(2), 1)));
assert_eq!(view.list().iter().map(|d| (d.stage.effect, d.reason)).collect::<Vec<_>>(), [(1, Reason::Budget)]);
assert_eq!(last_error(ID), None);
assert!(drawn(ID, &line(2), 1), "not cached: outside the view it runs to the end");
registry::remove(ID);
registry::remove(FAST);
}
/// A first run that is cancelled leaves the next run to be the first: the launch marker names
/// the plug-in during that one.
#[test]
+1 -2
View File
@@ -24,8 +24,7 @@ use crate::{Error, Kind, Limits, Plugin, Result, registry};
/// document: 200 times a filter's base budget.
pub const FUEL: u64 = 10_000_000_000;
/// Wall-clock budget of one export, in milliseconds (native builds; the web build has no clock,
/// and the instruction budget alone bounds it there).
/// Wall-clock budget of one export, in milliseconds.
pub const WALL_TIME_MS: u64 = 300_000;
/// Most warnings an export reports.
+18 -15
View File
@@ -318,33 +318,27 @@ pub struct Report {
pub truncated: bool,
}
/// When a run must stop (native: a wall-clock deadline).
/// The limit a run past its wall-clock deadline exceeded ([`Error::Limit`]).
pub(crate) const TIME_BUDGET: &str = "time budget";
/// When a run must stop: a deadline on the monotonic clock (`web_time::Instant`, the page's
/// `performance.now()` in the web build).
#[derive(Clone, Copy)]
pub(crate) struct Deadline {
#[cfg(not(target_arch = "wasm32"))]
at: std::time::Instant,
at: web_time::Instant,
}
impl Deadline {
pub(crate) fn new(wall_time_ms: u64) -> Self {
#[cfg(target_arch = "wasm32")]
let _ = wall_time_ms;
Self {
#[cfg(not(target_arch = "wasm32"))]
at: std::time::Instant::now() + std::time::Duration::from_millis(wall_time_ms),
}
Self { at: web_time::Instant::now() + std::time::Duration::from_millis(wall_time_ms) }
}
pub(crate) fn passed(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
if std::time::Instant::now() >= self.at {
return true;
}
false
web_time::Instant::now() >= self.at
}
pub(crate) fn check(&self) -> Result<()> {
if self.passed() { Err(Error::Limit("time budget".into())) } else { Ok(()) }
if self.passed() { Err(Error::Limit(TIME_BUDGET.into())) } else { Ok(()) }
}
}
@@ -813,6 +807,15 @@ mod tests {
Run::new("test", true, false, Deadline::new(60_000))
}
/// A deadline reads the monotonic clock that the web build also reads: one of 0 ms has
/// passed, and one a day away has not.
#[test]
fn a_deadline_passes_on_the_monotonic_clock() {
assert!(Deadline::new(0).passed());
assert!(matches!(Deadline::new(0).check(), Err(Error::Limit(l)) if l == TIME_BUDGET));
assert!(!Deadline::new(86_400_000).passed());
}
/// Progress is clamped: negative counts are 0 and `done` never passes a known total.
#[test]
fn progress_is_clamped() {
+2 -1
View File
@@ -9,7 +9,7 @@
//! permission): no WASI, file system, network, clock or randomness. A module that
//! imports anything else is rejected;
//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is
//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a
//! also enforced, so infinite loops end with an error. A filter may ask for a
//! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it;
//! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits;
//! - a **fresh instance per run**, so no state leaks between calls; what a plug-in keeps is only
@@ -34,6 +34,7 @@
//! runs through `vc_run` with the command's id in `_context`.
#![forbid(unsafe_code)]
pub mod deferred;
pub mod dialog;
pub mod effect;
pub mod export;
+3 -3
View File
@@ -29,7 +29,7 @@ const FUEL_COSTS: CustomFuelCosts = CustomFuelCosts { bytes_copied_per_fuel: 64,
/// the host learns that only once the run has ended.
pub const HEAVY_FUEL: u64 = 1_000_000_000;
/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds).
/// Wall-clock budget of a heavy filter's run, in milliseconds.
pub const HEAVY_WALL_TIME_MS: u64 = 300_000;
/// The limit a run reports when its input and parameters together are past half its memory.
@@ -56,8 +56,8 @@ pub struct Limits {
pub fuel_per_byte: u64,
/// Deepest call nesting.
pub max_recursion_depth: usize,
/// Wall-clock budget for one run, in milliseconds. Native builds only: the web build has no
/// monotonic clock in `std`, so the fuel budget alone bounds it there.
/// Wall-clock budget for one run, in milliseconds. The web build measures it with the page's
/// `performance.now()`.
pub wall_time_ms: u64,
/// The most a heavy filter ([`crate::Budget::Heavy`]) is given instead of `fuel_base` and
/// `wall_time_ms`: what the host allows, which the module's manifest can ask for but never
+22 -13
View File
@@ -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<Vec<(BezPath, FillRule)>>;
/// 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)))
}
+52 -2
View File
@@ -194,7 +194,7 @@ impl Importer {
_ => self.named("", NodeKind::Group { children: kids, clip: false })?,
};
// usvg clips a `<use>` 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<Node> {
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<Vec<(BezPath, FillRule)>> {
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<Node> {
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<NodeKind> {
let subs = PathData::from_bezpath(bp).subpaths;
+229 -22
View File
@@ -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, `<text>` and `<use>` as the children of a `<clipPath>`, so a `<text>`
//! there (and a `<use>` 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 `<text>` in a clip path that has an id also gets the usual
//! placeholder group after its path, and a `<use>` there with an id keeps a copy in a `<defs>`
//! after its path, for `<use>` 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<PendingText>,
/// 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<String, Affine>,
/// The glyph outlines of each text laid out so far, in text space, by index.
outlines: RefCell<HashMap<usize, Rc<BezPath>>>,
}
impl TextSlots {
/// The text a placeholder's main path stands for: by its id, or inside `<use>` 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 `<use>` instances (where usvg
/// drops ids) by its colour (the index) and its rectangle.
pub fn find(&self, p: &usvg::Path) -> Option<usize> {
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<Rc<BezPath>> {
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 `<text>` 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<usize>, 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 `<use>` 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 `<clipPath>`.
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 `<use>` 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 `<use>` 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 `<clipPath>`?
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 `<use>` in a clip path that shows a text by the clip placeholder of that text,
/// placed by the `<use>` (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<roxmltree::NodeId, usize>,
slots: &mut TextSlots,
edits: &mut Vec<(Range<usize>, String)>,
warnings: &mut Vec<String>,
) {
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 `<use>` 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 `<use>` is in the `<use>`'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 `<use>` can still show this one by its id: a copy of it stays in a `<defs>`, 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 `<use>` `u` in a `<defs>`: 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=\"{}\"/></{q}defs>", 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 `<use>` `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<XNode>, u: Option<XNode>) -> 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 `<g>`.
let mut style = String::new();
// The text's own non-inherited properties, overriding any rule that matches the `<g>`. 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")]
{
+5 -2
View File
@@ -78,8 +78,11 @@
//! 64 periods).
//! * `<image>` 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 `<text>` in a `<clipPath>`, or a `<use>` 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
//!
+429
View File
@@ -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: `<text>` and `<use>` 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#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300">{body}</svg>"#)
}
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<Node>] {
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<Node>]) {
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##"<defs><clipPath id="word"><text x="20" y="150" font-size="100" fill="green" stroke="blue">HI</text></clipPath></defs>
<rect id="art" width="400" height="300" fill="red" clip-path="url(#word)"/>"##,
);
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##"<clipPath id="c"><rect x="300" y="10" width="50" height="40"/><text x="20" y="150" font-size="100">O</text></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<clipPath id="c" clipPathUnits="objectBoundingBox">
<text transform="translate(0.5 0) scale(0.5 1)" x="0" y="0.8" font-size="0.8">I</text></clipPath>
<rect x="100" y="100" width="200" height="100" clip-path="url(#c)"/>"##,
);
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##"<clipPath id="c" transform="translate(100 20)"><text x="20" y="150" font-size="100">HI</text></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<clipPath id="half"><rect width="400" height="120"/></clipPath>
<clipPath id="c"><text x="20" y="150" font-size="100" clip-path="url(#half)">HI</text></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<clipPath id="c"><text x="20" y="150" font-size="100" clip-rule="evenodd">O</text></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<clipPath id="c"><text font-size="60"><tspan x="10" y="80">A</tspan><tspan x="10" y="200">B</tspan></text></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<defs><path id="arc" d="M20 200 Q200 20 380 200"/></defs>
<clipPath id="c"><text font-size="40"><textPath href="#arc">Around</textPath></text></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<defs><text id="t" x="0" y="0" font-size="100">HI</text></defs>
<clipPath id="c"><use href="#t" x="20" y="150"/></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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 `<use>`'s font size; an undisplayed `<use>` adds nothing.
let d = open(
r##"<defs><text id="t" x="0" y="0" font-size="100">HI</text></defs>
<clipPath id="c" font-size="40"><use href="#t" x="2em" y="3em"/><use href="#t" x="300" y="50" display="none"/></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<text id="t" x="0" y="100" font-size="50">HI</text>
<clipPath id="c"><use xlink:href="#t" transform="translate(200 0)"/><rect width="10" height="10"/></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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 `<use>`'s clip keeps x < 250 of its own
// user space, which the text's transform doesn't move.
let d = open(
r##"<defs><clipPath id="k"><rect width="250" height="300"/></clipPath>
<text id="t" transform="translate(200 0)" x="0" y="150" font-size="100">HI</text></defs>
<clipPath id="c"><use href="#t" clip-path="url(#k)"/></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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 `<use>`, the text's transform still places the type.
let d = open(
r##"<defs><text id="t" transform="translate(200 0)" x="0" y="150" font-size="100">HI</text></defs>
<clipPath id="c"><use href="#t" transform="translate(0 10)"/></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<style>.moved { transform: translate(100px, 20px) }</style>
<clipPath id="c"><text class="moved" x="20" y="150" font-size="100">HI</text></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<defs><text id="t" style="transform: translate(5px, 0)" x="0" y="0" font-size="100">HI</text></defs>
<clipPath id="c"><use href="#t" style="transform: translate(20px, 150px)"/></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<defs><rect id="t" x="300" width="50" height="50"/><text id="t" y="150" font-size="100">HI</text></defs>
<clipPath id="c"><use href="#t"/></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<clipPath id="c"><text id="w" x="20" y="150" font-size="100">HI</text></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>
<use href="#w" y="100"/>"##,
);
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##"<defs><text id="t" x="0" y="100" font-size="50">HI</text></defs>
<clipPath id="c"><use id="u" href="#t" x="20"/></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>
<use href="#u" y="100"/>"##,
);
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 `<use>` where it stands still applies to what the other use shows.
let d = open(
r##"<style>clipPath > use { transform: translate(40px, 0) }</style>
<defs><text id="t" x="0" y="100" font-size="50">HI</text></defs>
<clipPath id="c"><use id="u" xlink:href="#t" x="20"/></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>
<use href="#u" y="100"/>"##,
);
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##"<style>path { visibility: hidden } clipPath path { clip-rule: evenodd }</style>
<clipPath id="c"><text x="20" y="150" font-size="100">HI</text></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<style>path { clip-rule: nonzero }</style>
<clipPath id="c" clip-rule="evenodd"><text x="20" y="150" font-size="100">O</text></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<clipPath id="c"><text x="20" y="150" font-size="100" visibility="hidden">HI</text></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<clipPath id="c"><text x="20" y="150" font-size="100" systemLanguage="zz">HI</text><rect width="10" height="10"/></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<defs><symbol id="s" overflow="visible"><rect x="140" y="200" width="4" height="4"/></symbol>
<clipPath id="c">{clip}</clipPath></defs>
<use href="#s" clip-path="url(#c)"/>"##
)
};
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#"<text x="0" y="300" font-size="400">I I</text>"#));
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#"<rect width="400" height="300"/>"#));
assert_eq!(instances(&d), 1);
}
#[test]
fn a_clip_path_clipped_by_a_text_clip_path_nests() {
let d = open(
r##"<clipPath id="inner"><text x="20" y="150" font-size="100">HI</text></clipPath>
<clipPath id="outer" clip-path="url(#inner)"><rect width="400" height="120"/></clipPath>
<rect width="400" height="300" clip-path="url(#outer)"/>"##,
);
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##"<clipPath id="c"><text x="20" y="150" font-size="100" visibility="hidden">HI</text><rect width="10" height="10"/></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##,
);
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##"<clipPath id="c"><rect width="10" height="10"/><g><text y="50">No</text></g></clipPath>
<rect width="400" height="300" clip-path="url(#c)"/>"##;
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##"<clipPath id="c"><text x="20" y="150" font-size="100">HI</text></clipPath>
<rect width="400" height="300" fill="red" clip-path="url(#c)"/>"##,
);
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:?}"
);
}
+13
View File
@@ -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#"<style>text { transform: translate(10px, 20px) }</style><text transform="translate(300 0)" y="20">Moved</text>"#,
r#"<text style="transform: translate(10px, 20px)" transform="translate(300 0)" y="20">Moved</text>"#,
] {
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(
+1
View File
@@ -4,6 +4,7 @@
mod basic_shapes;
mod blend_export;
mod clip_text;
mod colorized_images;
mod colour_glyphs;
mod container_export;
+21 -5
View File
@@ -502,10 +502,26 @@ pub fn param_keys(doc: &str) -> Vec<String> {
out
}
/// Parameter-name keys that name filesystem paths: fuzzing these with arbitrary strings could
/// write files anywhere, so callers substitute a temp path.
/// Parameter-name keys that name a file or folder a command may write: fuzzing these with
/// arbitrary strings could write files anywhere, so callers substitute a temp path.
pub fn is_path_key(k: &str) -> bool {
k == "path"
matches!(k, "path" | "diffPath") || is_folder_key(k)
}
/// Path keys that name a folder a command creates or writes files into.
pub fn is_folder_key(k: &str) -> bool {
matches!(k, "folder" | "destinationFolder")
}
/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for
/// a folder key.
pub fn safe_location(k: &str, safe_path: &str) -> String {
if is_folder_key(k) { format!("{safe_path}-folder") } else { safe_path.to_string() }
}
/// `v`, or the temp location for `k` when `k` is a path key and `v` is a string.
pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value {
if is_path_key(k) && v.is_string() { json!(safe_location(k, safe_path)) } else { v.clone() }
}
/// A deterministic list of junk param objects for `doc` (the command's params doc): each
@@ -516,7 +532,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})];
for k in &keys {
for (i, v) in junk.iter().enumerate() {
let v = if is_path_key(k) && v.is_string() { json!(safe_path) } else { v.clone() };
let v = safe_value(k, v, safe_path);
// Sparse: one key at a time with every junk value, plus a few combos.
let mut o = serde_json::Map::new();
o.insert(k.clone(), v.clone());
@@ -524,7 +540,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
if i % 5 == 0 {
let mut all = serde_json::Map::new();
for k2 in &keys {
let v2 = if is_path_key(k2) { json!(safe_path) } else { v.clone() };
let v2 = if is_path_key(k2) { json!(safe_location(k2, safe_path)) } else { v.clone() };
all.insert(k2.clone(), v2);
}
out.push(Value::Object(all));
+2 -4
View File
@@ -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());
+3 -3
View File
@@ -123,9 +123,9 @@ pub struct CacheKey {
/// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since.
pub placed: u64,
/// The plug-in revision ([`vectorcraft_render::effects::plugin_revision`]) of the last plug-in
/// install or removal after which the document drew a plug-in effect
/// ([`vectorcraft_render::draws_plugin_effects`]): a frame drawn before it draws those effects
/// as the plug-ins installed then did. 0 until then.
/// install or removal, or of the last time deferred plug-in effect stages gave results, after
/// which the document drew a plug-in effect ([`vectorcraft_render::draws_plugin_effects`]): a
/// frame drawn before it draws those effects as the plug-ins installed then did. 0 until then.
pub plugins: u64,
/// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown
/// with hard edges. None: the art is rendered for the screen.
+29 -5
View File
@@ -293,6 +293,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let painter = ui.painter_at(rect);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let effects = st.plugin_effects.clone();
let mask_view = st.shown_mask();
paint_paper(app, st, ui, &painter, &xf, rect, (&t, app.outline_view()));
@@ -349,8 +350,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) {
let (w, h, view) = frame_view(pixel, (w, h), &v, ppp);
let opts = frame_options(app, &key, mask_view, smooth_images);
draw_frame(app, ui, Frame { key: &key, doc: &doc, size: (w, h), view, opts, pixel });
draw_frame(app, ui, Frame { key: &key, doc: &doc, effects: &effects, size: (w, h), view, opts, pixel });
}
report_deferred(app, &effects);
// The status bar reports each image that a renderer (this thread's or the worker's) could not
// decode, once per image in the process.
let undecoded = vectorcraft_render::newly_undecodable(doc.images.values());
@@ -606,7 +608,7 @@ fn cache_key(
/// [`CacheKey::plugins`] of document `st`, one of the documents open in `session`. `keys` holds each
/// document's value and the plug-in revision it was checked at, so a document is looked through only
/// once after each plug-in install or removal.
/// once after each change of the plug-in revision.
fn plugin_key(
keys: &mut std::collections::HashMap<u64, (u64, u64)>,
session: &vectorcraft_engine::Session,
@@ -624,6 +626,19 @@ fn plugin_key(
}
}
/// Once the first view of a document has been drawn, the status bar says how many plug-in effect
/// stages it left deferred, if any.
fn report_deferred(app: &mut VectorcraftApp, effects: &vectorcraft_plugins::deferred::Deferrals) {
if let Some(n) = effects.take_report().filter(|n| *n > 0) {
app.status(crate::i18n::msg_n(
n as u64,
"Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.",
"Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them.",
&[],
));
}
}
/// Start the render worker, and upload the frame it finished.
fn poll_worker(app: &mut VectorcraftApp, ui: &Ui, key_doc: usize) {
if !app.canvas.worker_started {
@@ -647,6 +662,9 @@ fn show_worker_frame(app: &mut VectorcraftApp, ctx: &egui::Context, done: crate:
return;
}
upload(app, ctx, done.img, done.key.pixel.is_some());
if let Some(d) = app.session.documents().iter().find(|d| d.uid as usize == done.key.doc) {
d.plugin_effects.first_view_done();
}
app.canvas.key = Some(done.key);
app.canvas.shown = Some((done.doc, done.opts));
app.perf.render_ms = done.ms;
@@ -729,6 +747,8 @@ fn frame_options(
struct Frame<'a> {
key: &'a CacheKey,
doc: &'a std::sync::Arc<vectorcraft_doc::Document>,
/// The document's deferred plug-in effect stages: the frame renders inside their view.
effects: &'a vectorcraft_plugins::deferred::Deferrals,
size: (u32, u32),
view: Affine,
opts: vectorcraft_render::RenderOptions,
@@ -740,7 +760,7 @@ struct Frame<'a> {
pub(crate) const FIRST_FRAME_GRACE: std::time::Duration = std::time::Duration::from_millis(16);
fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
let Frame { key, doc, size: (w, h), view, opts, pixel } = frame;
let Frame { key, doc, effects, size: (w, h), view, opts, pixel } = frame;
// Frames that would be slow on this thread (the last one took over 8 ms) go
// to the worker. An interaction's frames stay on the worker once it drew the frame shown:
// its renderer keeps the rasters of the art's effects that make those frames fast, and this
@@ -768,7 +788,8 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
app.canvas.renderer.let_go_of_measured();
}
if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) {
let part = app.canvas.renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts);
let renderer = &mut app.canvas.renderer;
let part = effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts));
let color = color_image(&part);
let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR };
tex.set_partial([x0 as usize, y0 as usize], color, options);
@@ -790,6 +811,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
view,
opts,
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
effects: effects.clone(),
may_copy,
may_keep: true,
});
@@ -801,7 +823,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
}
_ => {
let t0 = now_ms();
let img = app.canvas.renderer.render(doc, w, h, view, &opts);
let renderer = &mut app.canvas.renderer;
let img = effects.view(|| renderer.render(doc, w, h, view, &opts));
effects.first_view_done();
upload(app, ui.ctx(), img, pixel.is_some());
app.canvas.key = Some(key.clone());
app.canvas.shown = Some((doc.clone(), opts.clone()));
+2
View File
@@ -95,6 +95,7 @@ pub(super) fn fill_uncovered(
let (_, _, view) = fill_view(&done.key);
app.canvas.fill = Some(Fill { key: done.key, view, size: (fw, fh), tex });
}
let effects = app.session.documents().iter().find(|d| d.uid as usize == key.doc).map(|d| d.plugin_effects.clone()).unwrap_or_default();
let Some(worker) = app.canvas.fill_worker.as_mut().filter(|_| !gaps.is_empty()) else { return };
if !app.canvas.fill.as_ref().is_some_and(|f| covers(&f.key)) && !worker.pending.as_ref().is_some_and(covers) {
let (w, h, view) = fill_view(key);
@@ -106,6 +107,7 @@ pub(super) fn fill_uncovered(
view,
opts,
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
effects,
may_copy: false,
may_keep: false,
});
+39 -1
View File
@@ -178,7 +178,11 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's
/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left
/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else,
/// it makes none. The corner drags the rulers' origin or resets it on a double-click.
/// it makes none. A double-click on a ruler (both clicks on that ruler) makes a guide at the
/// pointer, across the ruler's axis (a vertical one on the top ruler, a horizontal one on the
/// left; swapped in a view turned 90°, where the rulers read the other document axis, and the
/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a
/// double-click.
pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> {
let [top, left, corner] = ruler_rects(full);
// Right-click on a ruler or the origin box: the document units, to swap between them as
@@ -189,6 +193,40 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
// (right). Not focusable (`click_and_drag` is): no key acts on a ruler, so Tab skips it.
let resp = ui.interact(r, egui::Id::unique(id), Sense::CLICK | Sense::DRAG);
resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
// egui counts a double-click by position and time alone, so a click on the canvas right
// at the ruler and one on the ruler would pass for one: the pair counts only when the
// ruler's own click before it came within the double-click delay.
let last_click = egui::Id::unique(if vertical { "ruler-left-last-click" } else { "ruler-top-last-click" });
let now = ui.input(|i| i.time);
let first_here =
ui.data(|d| d.get_temp::<f64>(last_click)).is_some_and(|t| now - t <= ui.ctx().options(|o| o.input_options.max_double_click_delay));
if resp.clicked_by(egui::PointerButton::Primary) {
ui.data_mut(|d| d.insert_temp(last_click, now));
}
if resp.double_clicked_by(egui::PointerButton::Primary)
&& first_here
&& let Some(p) = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()))
{
// The same document point the drag path reads. A ruler reads the canvas's coordinates
// turned with the view, so the guide is the one whose line crosses the ruler's axis:
// the vertical one when that axis (the screen's x for the top ruler, y for the left,
// in document directions) is nearer the document's x, else the horizontal one. At 0°
// that is a vertical guide from the top ruler; at 90° a horizontal one; between, the
// nearer axis, whose line still runs through the point and never along the ruler.
let at = xf.to_doc(p);
let (sn, cs) = xf.rot.sin_cos();
let axis = if vertical { (sn, cs) } else { (cs, -sn) };
let across = axis.0.abs() >= axis.1.abs();
let mut params = json!({"vertical": across, "pos": if across { at.x } else { at.y }});
if let Some(ab) = app.session.ruler_guide_artboard() {
params["artboard"] = json!(ab);
}
// The new guide shows even if guides were hidden.
app.ui.view.guides = true;
if let Err(e) = app.run("guide.add", params) {
app.status(e.to_string());
}
}
let kind = if resp.drag_stopped() {
PointerKind::Up
} else if resp.dragged() {
+1
View File
@@ -233,6 +233,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
for (label, icon, cmd, params) in &items {
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong);
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label));
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(3), t.hover);
}
+206
View File
@@ -2395,6 +2395,52 @@ fn undecodable_images_are_marked_and_reported_once() {
assert_eq!(app.ui.status, "", "said once");
}
/// The plug-in effects a document's first view leaves out past its budget are reported in the
/// status bar once, in the singular or the plural, and Effect › Refresh Deferred Effects is
/// enabled until it has run them.
#[test]
fn the_first_view_reports_the_plug_in_effects_it_deferred() {
use std::sync::atomic::{AtomicU64, Ordering};
use vectorcraft_plugins::wat as tpl;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.canvas.worker_started = true; // render on this thread
let module = tpl::module(&tpl::manifest("test.ui.deferred", "effect", "{}"), tpl::ECHO, "", 1);
app.session.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(module).unwrap())})).unwrap();
// Each reading of the clock is 3 s later: the first run's result comes back after the budget,
// and no later run starts.
let step = std::sync::Arc::new(AtomicU64::new(3_000));
let ctx = egui::Context::default();
for (count, said) in [
(2, "Opened with 2 plug-in effects deferred. Effect › Refresh Deferred Effects runs them."),
(1, "Opened with 1 plug-in effect deferred. Effect › Refresh Deferred Effects runs it."),
] {
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
// Art the plug-in has not been given before: a result it gave is kept and drawn at once.
for i in 0..count {
let x = 31.0 + 97.0 * f64::from(i) + 13.0 * f64::from(count);
let id = app.session.execute("shape.rectangle", &json!({"x": x, "y": 47, "width": 23, "height": 29})).unwrap()["id"].clone();
app.session.execute("effect.apply", &json!({"ids": [id], "effect": "plugin.test.ui.deferred"})).unwrap();
}
app.session.execute("select.none", &json!({})).unwrap();
let (now, by) = (std::sync::Arc::new(AtomicU64::new(0)), step.clone());
let clock: vectorcraft_plugins::deferred::Clock =
std::sync::Arc::new(move || Some(now.fetch_add(by.load(Ordering::Relaxed), Ordering::Relaxed)));
app.session.doc_mut().unwrap().plugin_effects = vectorcraft_plugins::deferred::Deferrals::with_clock(clock);
step.store(3_000, Ordering::Relaxed);
app.status("");
frame(&mut app, &ctx, vec![]);
assert_eq!(app.ui.status, said);
assert!(crate::menus::enabled(&app, "effect.refreshDeferred"));
app.status("");
app.session.doc_mut().unwrap().revision += 1;
frame(&mut app, &ctx, vec![]);
assert_eq!(app.ui.status, "", "said once");
step.store(0, Ordering::Relaxed);
app.session.execute("effect.refreshDeferred", &json!({})).unwrap();
assert!(!crate::menus::enabled(&app, "effect.refreshDeferred"), "nothing is left deferred");
}
}
/// A drag of an object with raster effects (#1257): the drag's frames render while
/// interacting and draw the object's drop shadow and filtered content from the rasters of the
/// frame before; the frame after the drag renders without `interacting`, on the render worker,
@@ -9198,6 +9244,20 @@ fn the_task_bar_offers_the_artboard_tools_actions() {
assert_eq!(names(&mut app), ["Resize", "Duplicate", "Export"]);
}
/// Screen readers name the Contextual Task Bar's text buttons by their labels.
#[test]
fn screen_readers_name_the_task_bars_text_buttons() {
use crate::widgets::accessibility_tests::{named, tree};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap();
app.select_tool("selection");
let tree = tree(|ui| show(&mut app, ui));
for label in ["Edit Path", "Offset Path", "Simplify", "Duplicate"] {
assert_eq!(named(&tree, label).role(), egui::accesskit::Role::Button, "{label}");
}
}
/// A selected group's Contextual Task Bar offers Ungroup and Isolate. While an opacity mask is
/// edited, when Enter Isolation Mode is off, a group in the mask art gets Ungroup without Isolate.
#[test]
@@ -10687,6 +10747,7 @@ fn pan_job(doc: &std::sync::Arc<vectorcraft_doc::Document>, key: &CacheKey, (dx,
view: Affine::translate((dx, dy)),
opts: vectorcraft_render::RenderOptions::default(),
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
effects: Default::default(),
may_copy: true,
may_keep: true,
}
@@ -11304,6 +11365,151 @@ fn direct_selection_shows_the_bounding_box_while_cmd_is_held() {
assert!(app.ui.view.bounding_box, "View › Show Bounding Box is unchanged");
}
/// #5239: a double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler
/// at its x, a horizontal one on the left ruler at its y, as one undo step, shown even when
/// guides were hidden. The first click of the pair adds nothing.
#[test]
fn double_clicking_a_ruler_adds_a_guide_at_that_position() {
// The ruler to click, the guide's orientation and its position, the pointer at that position.
for (top, vertical, pos) in [(true, true, 120.0), (false, false, 80.0)] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.ui.view.rulers = true;
app.ui.view.guides = false;
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let view = app.view_mut().unwrap();
view.zoom = 2.0;
view.rotation = 0.0;
view.center = Point::new(200.0, 150.0);
frame(&mut app, &ctx, vec![]);
let rect = app.canvas_rect.unwrap();
let xf = Xf::new(rect, app.view().unwrap());
let at = if top {
pos2(xf.to_screen(Point::new(pos, 0.0)).x, rect.top() - RULER / 2.0)
} else {
pos2(rect.left() - RULER / 2.0, xf.to_screen(Point::new(0.0, pos)).y)
};
let guides = |app: &mut VectorcraftApp| {
let list = app.session.execute("guide.list", &json!({})).unwrap();
list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect::<Vec<_>>()
};
let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let before = steps(&app);
let click = |app: &mut VectorcraftApp| {
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
frame(app, &ctx, vec![button(true)]);
frame(app, &ctx, vec![button(false)]);
};
click(&mut app);
assert_eq!(guides(&mut app), vec![], "a single click adds none");
click(&mut app);
let got = guides(&mut app);
assert_eq!(got.len(), 1, "top ruler: {top}, {got:?}");
assert!(got[0].0 == vertical && (got[0].1 - pos).abs() < 0.01, "top ruler: {top}, want vertical {vertical} at {pos}: {got:?}");
assert!(app.ui.view.guides, "the new guide is shown");
assert_eq!(steps(&app), before + 1, "one undo step");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(guides(&mut app), vec![]);
}
}
/// A fresh app with a 400 x 300 document, rulers on, the view at zoom 2 turned `rotation` degrees,
/// and its first frame drawn.
fn ruler_guide_app(rotation: f64) -> (VectorcraftApp, egui::Context) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.ui.view.rulers = true;
app.ui.view.guides = false;
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let view = app.view_mut().unwrap();
view.zoom = 2.0;
view.rotation = rotation;
view.center = Point::new(200.0, 150.0);
frame(&mut app, &ctx, vec![]);
(app, ctx)
}
/// One primary click at `at`, as three frames (move, press, release).
fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) {
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, ctx, vec![egui::Event::PointerMoved(at)]);
frame(app, ctx, vec![button(true)]);
frame(app, ctx, vec![button(false)]);
}
/// The ruler guides as (vertical, position).
fn guide_list(app: &mut VectorcraftApp) -> Vec<(bool, f64)> {
let list = app.session.execute("guide.list", &json!({})).unwrap();
list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect()
}
/// #5239 review: egui counts a double-click by position and time, not by widget, so a click on
/// the canvas right at a ruler followed at once by a click on the ruler is not a double-click on
/// the ruler. No guide; two clicks on the ruler still make one.
#[test]
fn a_canvas_click_then_a_ruler_click_adds_no_guide() {
for (top, canvas_first) in [(true, true), (false, true), (true, false), (false, false)] {
let (mut app, ctx) = ruler_guide_app(0.0);
let rect = app.canvas_rect.unwrap();
// Two points less than 6 pt apart, either side of the ruler's inner edge.
let (on_canvas, on_ruler) = if top {
(pos2(rect.center().x, rect.top() + 1.0), pos2(rect.center().x, rect.top() - 1.0))
} else {
(pos2(rect.left() + 1.0, rect.center().y), pos2(rect.left() - 1.0, rect.center().y))
};
click_at(&mut app, &ctx, if canvas_first { on_canvas } else { on_ruler });
click_at(&mut app, &ctx, on_ruler);
let want = usize::from(!canvas_first);
assert_eq!(guide_list(&mut app).len(), want, "top ruler: {top}, first click on the canvas: {canvas_first}");
}
}
/// #5239 review: the rulers read the canvas's coordinates turned with the view, so a guide made
/// by a double-click runs across the ruler's axis in any view: it passes through the clicked
/// point and crosses the ruler's edge at it, never along it. At 90° the top ruler reads the
/// document's y, so its guide is horizontal; the left ruler reads x, so its guide is vertical.
/// A view turned part of the way takes the document axis nearer to the ruler's.
#[test]
fn a_ruler_double_click_follows_a_rotated_view() {
// Rotation (degrees), top ruler's guide is vertical, left ruler's guide is vertical.
for (rotation, top_vertical, left_vertical) in [
(0.0, true, false),
(90.0, false, true),
(-90.0, false, true),
(180.0, true, false),
(270.0, false, true),
(30.0, true, false),
(60.0, false, true),
] {
for top in [true, false] {
let (mut app, ctx) = ruler_guide_app(rotation);
let rect = app.canvas_rect.unwrap();
let xf = Xf::new(rect, app.view().unwrap());
let at =
if top { pos2(rect.center().x + 37.0, rect.top() - RULER / 2.0) } else { pos2(rect.left() - RULER / 2.0, rect.center().y + 23.0) };
click_at(&mut app, &ctx, at);
click_at(&mut app, &ctx, at);
let got = guide_list(&mut app);
let label = format!("rotation {rotation}, top ruler: {top}: {got:?}");
assert_eq!(got.len(), 1, "{label}");
let (vertical, pos) = got[0];
assert_eq!(vertical, if top { top_vertical } else { left_vertical }, "{label}");
// Through the clicked point.
let doc = xf.to_doc(at);
assert!((pos - if vertical { doc.x } else { doc.y }).abs() < 0.01, "{label}: through {doc:?}");
// Its direction on screen is not along the ruler: the line crosses the ruler's edge.
let along = if vertical { Point::new(doc.x, doc.y + 1.0) } else { Point::new(doc.x + 1.0, doc.y) };
let dir = (xf.to_screen(along) - xf.to_screen(doc)).normalized();
let across = if top { dir.y.abs() } else { dir.x.abs() };
assert!(across > 0.5, "{label}: the guide runs along the ruler ({dir:?})");
}
}
}
/// An edit to a 3D effect renders the part it changed on this thread, measured by the renderer
/// that drew the frame shown ([`vectorcraft_render::Renderer::changed`]): it draws the part with
/// the 3D art it made to measure it.
+4
View File
@@ -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.
+192 -3
View File
@@ -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<Value, String>
}
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<Value, String> {
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<Value, String> {
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",
}
}
+18
View File
@@ -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. Да се спре ли скриптът и да се отменят ли промените му? След това излезте отново.
@@ -5891,6 +5899,12 @@ axis Y Axis Y ос
Flag Флаг
Crosshair Мерник
Checkpoint Контролна точка
Inventory Icons Икони на инвентара
Sword Меч
Shield Щит
Potion Отвара
Key Ключ
Gem Скъпоценен камък
Open Symbol Library Отваряне на библиотека със символи
Save Symbol Library Запазване на библиотека със символи
Save Symbol Library… Запази библиотека със символи…
@@ -6509,3 +6523,7 @@ breakText Glyphs Глифи
Ended by an internal fault Прекратен поради вътрешна грешка
{ms} ms, {calls} host calls {ms} ms, {calls} извиквания към хоста
{dropped} earlier lines dropped, {cut} lines cut {dropped} по-ранни реда премахнати, {cut} реда съкратени
# Deferred plug-in effects
Refresh Deferred Effects Обновяване на отложените ефекти
Deferred Effects Отложени ефекти
Can't render this file because it contains an unexpected character in line 2 and column 73.
+18
View File
@@ -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.
@@ -5935,6 +5943,12 @@ axis Y Axis Y osa
Flag Zastava
Crosshair Nišan
Checkpoint Kontrolna tačka
Inventory Icons Ikone inventara
Sword Mač
Shield Štit
Potion Napitak
Key Ključ
Gem Dragulj
Open Symbol Library Otvori biblioteku simbola
Save Symbol Library Sačuvaj biblioteku simbola
Save Symbol Library… Sačuvaj biblioteku simbola…
@@ -6560,3 +6574,7 @@ shape Open Otvoren
Ended by an internal fault Završeno zbog interne greške
{ms} ms, {calls} host calls {ms} ms, {calls} poziva hosta
{dropped} earlier lines dropped, {cut} lines cut {dropped} ranijih redova uklonjeno, {cut} redova skraćeno
# Deferred plug-in effects
Refresh Deferred Effects Osvježi odgođene efekte
Deferred Effects Odgođeni efekti
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -455,6 +455,12 @@ antiAlias Sharp Nítid
Flag Bandera
Crosshair Punt de mira
Checkpoint Punt de control
Inventory Icons Icones d’inventari
Sword Espasa
Shield Escut
Potion Poció
Key Clau
Gem Gemm
Batch Lot
Batch… Lot…
Bearing Orientació
@@ -3949,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
@@ -5190,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.
@@ -6765,3 +6779,9 @@ breakText Glyphs Glifs
{ms} ms, {calls} host calls {ms} ms, {calls} crides a l’amfitrió
{dropped} earlier lines dropped, {cut} lines cut {dropped} línies anteriors descartades, {cut} línies tallades
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` han de ser números de línies que la consola conserva, `first` abans de `last`
# Deferred plug-in effects
Refresh Deferred Effects Actualitza els efectes ajornats
Deferred Effects Efectes ajornats
@msg no plug-in effect is deferred no hi ha cap efecte de complement ajornat
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. S’ha obert amb {n} efecte de complement ajornat. Efecte › Actualitza els efectes ajornats l’executa.|S’ha obert amb {n} efectes de complement ajornats. Efecte › Actualitza els efectes ajornats els executa.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -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.
@@ -6119,6 +6127,12 @@ axis Y Axis osa Y
Flag Vlajka
Crosshair Zaměřovač
Checkpoint Kontrolní bod
Inventory Icons Ikony inventáře
Sword Meč
Shield Štít
Potion Lektvar
Key Klíč
Gem Drahokam
Open Symbol Library Otevřít knihovnu symbolů
Save Symbol Library Uložit knihovnu symbolů
Save Symbol Library… Uložit knihovnu symbolů…
@@ -6784,3 +6798,9 @@ breakText Glyphs Glyfy
{ms} ms, {calls} host calls {ms} ms, {calls} volání hostitele
{dropped} earlier lines dropped, {cut} lines cut {dropped} starších řádků zahozeno, {cut} řádků zkráceno
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last`
# Deferred plug-in effects
Refresh Deferred Effects Obnovit odložené efekty
Deferred Effects Odložené efekty
@msg no plug-in effect is deferred žádný efekt zásuvného modulu není odložen
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otevřeno s {n} odloženým efektem zásuvného modulu. Efekt › Obnovit odložené efekty jej spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+18
View File
@@ -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.
@@ -5906,6 +5914,12 @@ axis Y Axis Y-akse
Flag Flag
Crosshair Sigtekorn
Checkpoint Kontrolpunkt
Inventory Icons Inventarikoner
Sword Sværd
Shield Skjold
Potion Eliksir
Key Nøgle
Gem Ædelsten
Open Symbol Library Åbn symbolbibliotek
Save Symbol Library Gem symbolbibliotek
Save Symbol Library… Gem symbolbibliotek…
@@ -6523,3 +6537,7 @@ breakText Glyphs Glyffer
Ended by an internal fault Afsluttet af en intern fejl
{ms} ms, {calls} host calls {ms} ms, {calls} værtskald
{dropped} earlier lines dropped, {cut} lines cut {dropped} tidligere linjer fjernet, {cut} linjer afkortet
# Deferred plug-in effects
Refresh Deferred Effects Opdatér udskudte effekter
Deferred Effects Udskudte effekter
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -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.
@@ -5702,6 +5710,12 @@ axis Y Axis Y-Achse
Flag Flagge
Crosshair Fadenkreuz
Checkpoint Kontrollpunkt
Inventory Icons Inventar-Symbole
Sword Schwert
Shield Schild
Potion Trank
Key Schlüssel
Gem Edelstein
Open Symbol Library Symbol-Bibliothek öffnen
Save Symbol Library Symbol-Bibliothek speichern
Save Symbol Library… Symbol-Bibliothek speichern…
@@ -6371,3 +6385,9 @@ breakText Glyphs Glyphen
{ms} ms, {calls} host calls {ms} ms, {calls} Host-Aufrufe
{dropped} earlier lines dropped, {cut} lines cut {dropped} ältere Zeilen verworfen, {cut} Zeilen gekürzt
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` und `last` müssen Nummern von Zeilen sein, die die Konsole enthält, `first` vor `last`
# Deferred plug-in effects
Refresh Deferred Effects Zurückgestellte Effekte aktualisieren
Deferred Effects Zurückgestellte Effekte
@msg no plug-in effect is deferred kein Plug-in-Effekt ist zurückgestellt
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Mit {n} zurückgestelltem Plug-in-Effekt geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt ihn aus.|Mit {n} zurückgestellten Plug-in-Effekten geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt sie aus.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -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 Λειτουργία σχήματος
@@ -6070,6 +6078,12 @@ axis Y Axis Άξονας Y
Flag Σημαία
Crosshair Σταυρόνημα
Checkpoint Σημείο ελέγχου
Inventory Icons Εικονίδια αποθέματος
Sword Ξίφος
Shield Ασπίδα
Potion Φίλτρο
Key Κλειδί
Gem Πετράδι
Open Symbol Library Άνοιγμα βιβλιοθήκης συμβόλων
Save Symbol Library Αποθήκευση βιβλιοθήκης συμβόλων
Save Symbol Library… Αποθήκευση βιβλιοθήκης συμβόλων…
@@ -6736,3 +6750,9 @@ breakText Glyphs Γλύφοι
{ms} ms, {calls} host calls {ms} ms, {calls} κλήσεις προς το κεντρικό σύστημα
{dropped} earlier lines dropped, {cut} lines cut {dropped} παλαιότερες γραμμές απορρίφθηκαν, {cut} γραμμές περικόπηκαν
@msg `first` and `last` must be numbers of lines the console holds, first before last Τα `first` και `last` πρέπει να είναι αριθμοί γραμμών που κρατά η κονσόλα, το `first` πριν από το `last`
# Deferred plug-in effects
Refresh Deferred Effects Ανανέωση εφέ σε αναμονή
Deferred Effects Εφέ σε αναμονή
@msg no plug-in effect is deferred κανένα εφέ προσθέτου δεν είναι σε αναμονή
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Άνοιξε με {n} εφέ προσθέτου σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή το εκτελεί.|Άνοιξε με {n} εφέ προσθέτων σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή τα εκτελεί.
Can't render this file because it is too large.
+20
View File
@@ -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.
@@ -6061,6 +6069,12 @@ axis Y Axis Eje Y
Flag Bandera
Crosshair Punto de mira
Checkpoint Punto de control
Inventory Icons Iconos de inventario
Sword Espada
Shield Escudo
Potion Poción
Key Llave
Gem Gema
Open Symbol Library Abrir biblioteca de símbolos
Save Symbol Library Guardar biblioteca de símbolos
Save Symbol Library… Guardar biblioteca de símbolos…
@@ -6727,3 +6741,9 @@ breakText Glyphs Glifos
{ms} ms, {calls} host calls {ms} ms, {calls} llamadas al host
{dropped} earlier lines dropped, {cut} lines cut {dropped} líneas anteriores descartadas, {cut} líneas recortadas
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` y `last` deben ser números de líneas que la consola conserva, `first` antes que `last`
# Deferred plug-in effects
Refresh Deferred Effects Actualizar efectos aplazados
Deferred Effects Efectos aplazados
@msg no plug-in effect is deferred no hay ningún efecto de plugin aplazado
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Se abrió con {n} efecto de plugin aplazado. Efecto › Actualizar efectos aplazados lo ejecuta.|Se abrió con {n} efectos de plugin aplazados. Efecto › Actualizar efectos aplazados los ejecuta.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -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.
@@ -6149,6 +6157,12 @@ axis Y Axis Y-akseli
Flag Lippu
Crosshair Tähtäin
Checkpoint Tarkastuskohta
Inventory Icons Inventaarioikonit
Sword Miekka
Shield Kilpi
Potion Taikajuoma
Key Avain
Gem Jalokivi
Open Symbol Library Avaa symbolikirjasto
Save Symbol Library Tallenna symbolikirjasto
Save Symbol Library… Tallenna symbolikirjasto…
@@ -6819,3 +6833,9 @@ breakText Glyphs Merkit
{ms} ms, {calls} host calls {ms} ms, {calls} isäntäkutsua
{dropped} earlier lines dropped, {cut} lines cut {dropped} aiempaa riviä poistettu, {cut} riviä katkaistu
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` ja `last` on oltava konsolin säilyttämien rivien numeroita, `first` ennen arvoa `last`
# Deferred plug-in effects
Refresh Deferred Effects Päivitä lykätyt tehosteet
Deferred Effects Lykätyt tehosteet
@msg no plug-in effect is deferred yhtään laajennustehostetta ei ole lykätty
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Avattiin, ja {n} laajennustehoste lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa sen.|Avattiin, ja {n} laajennustehostetta lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa ne.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -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.
@@ -6114,6 +6122,12 @@ axis Y Axis Axe Y
Flag Drapeau
Crosshair Réticule
Checkpoint Point de contrôle
Inventory Icons Icônes d’inventaire
Sword Épée
Shield Bouclier
Potion Potion
Key Clé
Gem Gemme
Open Symbol Library Ouvrir une bibliothèque de symboles
Save Symbol Library Enregistrer la bibliothèque de symboles
Save Symbol Library… Enregistrer la bibliothèque de symboles…
@@ -6779,3 +6793,9 @@ breakText Glyphs Glyphes
{ms} ms, {calls} host calls {ms} ms, {calls} appels à l’hôte
{dropped} earlier lines dropped, {cut} lines cut {dropped} lignes antérieures supprimées, {cut} lignes coupées
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` et `last` doivent être des numéros de lignes que la console contient, `first` avant `last`
# Deferred plug-in effects
Refresh Deferred Effects Actualiser les effets différés
Deferred Effects Effets différés
@msg no plug-in effect is deferred aucun effet de plug-in n’est différé
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Ouvert avec {n} effet de plug-in différé. Effet › Actualiser les effets différés l’exécute.|Ouvert avec {n} effets de plug-in différés. Effet › Actualiser les effets différés les exécute.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -526,6 +526,12 @@ antiAlias Sharp शार्प
Flag झंडा
Crosshair क्रॉसहेयर
Checkpoint चेकपॉइंट
Inventory Icons इन्वेंटरी आइकन
Sword तलवार
Shield ढाल
Potion औषधि
Key कुंजी
Gem रत्न
Batch बैच
Batch Trace Folder फ़ोल्डर बैच ट्रेस करें
Batch… बैच…
@@ -4261,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 शेप बिल्डर
@@ -6180,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. स्क्रिप्ट रोककर उसके बदलाव पूर्ववत करें? फिर दोबारा बाहर निकलें।
@@ -8225,3 +8239,9 @@ theme Appearance अपीयरेंस
{ms} ms, {calls} host calls {ms} ms, {calls} होस्ट कॉल
{dropped} earlier lines dropped, {cut} lines cut {dropped} पुरानी पंक्तियाँ हटाई गईं, {cut} पंक्तियाँ काटी गईं
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` और `last` कंसोल में रखी पंक्तियों की संख्याएँ होनी चाहिए, `first` पहले और `last` बाद में
# Deferred plug-in effects
Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें
Deferred Effects स्थगित इफ़ेक्ट्स
@msg no plug-in effect is deferred कोई प्लग-इन इफ़ेक्ट स्थगित नहीं है
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} प्लग-इन इफ़ेक्ट स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इसे चलाता है।|{n} प्लग-इन इफ़ेक्ट्स स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इन्हें चलाता है।
Can't render this file because it is too large.

Some files were not shown because too many files have changed in this diff Show More