#6369: Merge main and retain halftone roadmap evidence

This commit is contained in:
makesomethingshit
2026-11-10 22:05:29 +09:00
169 changed files with 8457 additions and 491 deletions
Generated
+4 -3
View File
@@ -975,12 +975,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1049,7 +1049,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -6499,6 +6499,7 @@ dependencies = [
"ruzstd",
"serde",
"serde_json",
"skrifa 0.47.0",
"thiserror 2.0.21",
"vectorcraft-affinity",
"vectorcraft-apischema",
+1 -1
View File
@@ -14,7 +14,7 @@ repository = "https://github.com/storytold/vectorcraft"
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["serde"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "bb8320f5fa5d20fa78a026d0de06fe4e2f08a099", features = ["serde"] }
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
+1 -1
View File
@@ -597,7 +597,7 @@ fn exercise_mcp_core(
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
mcp_send(stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3));
assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 4));
mcp_send(stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
assert!(mcp_recv(lines, notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
+1
View File
@@ -5,3 +5,4 @@
mod cli;
mod fileio;
mod mcp_e2e;
mod script;
+39
View File
@@ -0,0 +1,39 @@
use std::process::Command;
use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// `run` with `script.run` after `file.new`, with `params`.
fn run_script(params: &Value) -> std::process::Output {
Command::new(BIN).args(["run", "--cmd", "file.new", "--cmd", "script.run", "--params", &params.to_string()]).output().unwrap()
}
/// `run` runs a script headless: the reply holds the report, with what the script logged and the
/// documents it changed.
#[test]
fn run_gives_the_report_of_a_script() {
let source = "vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10}); vc.log(vc.args.n * 2);";
let out = run_script(&json!({"source": source, "name": "double.js", "args": {"n": 21}}));
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 2, "{lines:?}");
assert_eq!(lines[1]["command"], "script.run");
let report = &lines[1]["result"];
assert_eq!(report["ok"], true, "{report}");
assert_eq!(report["name"], "double.js");
assert_eq!(report["log"], json!([42]), "{report}");
assert_eq!(report["changed"].as_array().map(Vec::len), Some(1), "{report}");
}
/// A script that throws fails its step with the script's message, and the steps before it ran.
#[test]
fn run_fails_the_step_of_a_script_that_throws() {
let out = run_script(&json!({"source": "vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10}); throw new Error('no margin given');"}));
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("step 1 (script.run)") && stderr.contains("no margin given"), "{stderr}");
let printed: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(printed.len(), 1, "only file.new printed: {printed:?}");
assert_eq!(printed[0]["command"], "file.new");
}
+1 -1
View File
@@ -26,7 +26,7 @@ log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
rfd = { workspace = true }
rfd = { workspace = true, features = ["file-handle-inner"] }
wasm-bindgen = "0.2"
serde_json = { workspace = true }
wasm-bindgen-futures = "0.4"
+31 -2
View File
@@ -9,6 +9,9 @@ use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour, is_address};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
use vectorcraft_engine::cmd::scripts::RUN_EXTS;
use vectorcraft_engine::script::MAX_SOURCE_BYTES;
use vectorcraft_ui_egui::dialogs::script_run::ScriptInbox;
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::i18n;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
@@ -47,6 +50,7 @@ struct Host {
runner: eframe::WebRunner,
inbox: Inbox,
place_inbox: PlaceInbox,
script_inbox: ScriptInbox,
drag: DragPos,
pasted: Rc<RefCell<Pasted>>,
/// Pen and finger input read from the page's Pointer Events (`crate::pencil`).
@@ -75,6 +79,7 @@ pub fn start() {
runner: eframe::WebRunner::new(),
inbox: Arc::default(),
place_inbox: Arc::default(),
script_inbox: Arc::default(),
drag: DragPos::default(),
pasted: Rc::default(),
pointers: SharedPointers::default(),
@@ -120,7 +125,8 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
}
host.pasted.borrow_mut().ctx = Some(ctx.clone());
let stylus = host.pointers.borrow().stylus();
let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone(), stylus);
let services =
services(host.inbox.clone(), host.place_inbox.clone(), host.script_inbox.clone(), ctx.clone(), host.locks.clone(), stylus);
let app = match taken.borrow_mut().take() {
Some(mut app) => {
// The old services asked the old context for frames.
@@ -425,12 +431,35 @@ impl eframe::App for WebShell {
}
}
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option<WebLocks>, stylus: StylusFeed) -> Services {
fn services(
inbox: Inbox,
place_inbox: PlaceInbox,
script_inbox: ScriptInbox,
ctx: egui::Context,
locks: Option<WebLocks>,
stylus: StylusFeed,
) -> Services {
let open_inbox = inbox.clone();
let picked = place_inbox.clone();
let place_ctx = ctx.clone();
let screen_color = eye_dropper(ctx.clone());
let (script_picked, script_ctx) = (script_inbox.clone(), ctx.clone());
Services {
// File › Scripts › Other Script…: the picked file goes to the warning before it runs.
script_async: Some(Box::new(move || {
let inbox = script_picked.clone();
let ctx = script_ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let filter = vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", "Scripts");
let Some(file) = rfd::AsyncFileDialog::new().add_filter(filter, RUN_EXTS).pick_file().await else {
return;
};
let bytes = if file.inner().size() > MAX_SOURCE_BYTES as f64 { None } else { Some(file.read().await) };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
});
})),
script_inbox: Some(script_inbox),
// File → Place…: the picked files go to the Place dialog.
place_async: Some(Box::new(move || {
let inbox = picked.clone();
+1
View File
@@ -750,6 +750,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64
if resized {
o.cached_bounds = None;
o.cached_baselines.clear();
o.cached_ink = None;
}
}
+5
View File
@@ -276,6 +276,11 @@ impl Document {
self.next_id
}
/// Makes the next id at least `id`, so ids below it are not given out again.
pub fn raise_next_id(&mut self, id: u64) {
self.next_id = self.next_id.max(id);
}
/// Ensure `next_id` is above every id in the tree (after deserializing foreign data).
pub fn fix_next_id(&mut self) {
// Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a
+23 -15
View File
@@ -123,18 +123,33 @@ impl Document {
std::borrow::Cow::Owned(d)
}
/// Does `wants` hold for a node anywhere in the document's art: the layers, symbol definitions,
/// graph designs, pattern swatches (the one being edited included) and their opacity masks?
pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool {
let roots = self
.layers
/// The roots of every tree of the document's art: the layers, symbol definitions, graph designs,
/// pattern swatches and the original of the pattern being edited. Opacity masks hang off nodes
/// in these trees.
pub fn art_roots(&self) -> impl Iterator<Item = &Arc<Node>> {
self.layers
.iter()
.chain(self.symbols.iter().map(|s| &s.art))
.chain(self.graph_designs.iter().map(|g| &g.art))
.chain(self.patterns.iter().flat_map(|p| p.art.iter()))
.chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter()));
.chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter()))
}
/// [`Self::art_roots`], to change.
pub fn art_roots_mut(&mut self) -> impl Iterator<Item = &mut Arc<Node>> {
self.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()))
}
/// Does `wants` hold for a node anywhere in the document's art ([`Self::art_roots`]) or its
/// opacity masks?
pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool {
let mut any = false;
for root in roots {
for root in self.art_roots() {
crate::swatches::walk_with_masks(root, &mut |n| any |= wants(n));
}
any
@@ -144,14 +159,7 @@ impl Document {
/// [`Self::any_node`] looks). Only the trees that hold such a node are copied out of the art
/// they share.
pub fn edit_nodes_where(&mut self, wants: impl Fn(&Node) -> bool, mut f: impl FnMut(&mut Node)) {
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
for root in self.art_roots_mut() {
edit_branches_where(root, &wants, &mut f);
}
}
+1
View File
@@ -347,6 +347,7 @@ impl Document {
t.first_para_continues = false;
t.cached_bounds = None;
t.cached_baselines.clear();
t.cached_ink = None;
}
let on_path = n.extras().objects_on_path.is_some();
let mut renamed = std::collections::HashMap::new();
+1
View File
@@ -71,6 +71,7 @@ impl Document {
}
if changed {
t.cached_bounds = None;
t.cached_ink = None;
}
changed
}
+1
View File
@@ -236,6 +236,7 @@ impl TypeInks {
text.xf = Affine::new([a, b, c, d, 0.0, 0.0]);
text.cached_bounds = None;
text.cached_baselines.clear();
text.cached_ink = None;
let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs));
match content.and_then(|key| self.content.get(&key)) {
Some(ink) => {
+6
View File
@@ -121,6 +121,12 @@ pub struct TextObject {
/// vertical column's centre line), filled in with `cached_bounds` (not serialized).
#[serde(skip)]
pub cached_baselines: Vec<(Point, Point)>,
/// Cached ink of the glyphs in text space, underline and strikethrough bars included
/// (`vectorcraft_effects::preview::laid_out_ink`, `None` when nothing draws),
/// with the `cached_bounds` it was measured with: filled in with `cached_bounds` by the engine's
/// text edits, and stale once `cached_bounds` differs ([`Self::ink`]). Not serialized.
#[serde(skip)]
pub cached_ink: Option<(Rect, Option<Rect>)>,
}
/// Conversion from stored character attributes to document-space type controls.
+8
View File
@@ -71,6 +71,7 @@ impl TextObject {
self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy);
self.cached_bounds = None;
self.cached_baselines.clear();
self.cached_ink = None;
true
}
@@ -97,6 +98,7 @@ impl TextObject {
list_level_counts: Vec::new(),
cached_bounds: None,
cached_baselines: Vec::new(),
cached_ink: None,
}
}
/// The list counts the story's earlier frames carry into this frame at each level
@@ -288,6 +290,12 @@ impl TextObject {
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
}
}
/// The cached ink of the glyphs in text space ([`Self::cached_ink`]): `Some(None)` when nothing
/// draws, and `None` when it was not measured with the current `cached_bounds`.
pub fn ink(&self) -> Option<Option<Rect>> {
let (bounds, ink) = self.cached_ink?;
(self.cached_bounds == Some(bounds)).then_some(ink)
}
/// How far the characters' strokes paint past their glyphs, in document space: they scale
/// with the type.
pub fn char_stroke_outset(&self) -> f64 {
+117
View File
@@ -17,6 +17,8 @@
//! [`Selection::anchor_bounds`] measure the objects themselves. Raster effects (shadows, glows,
//! blurs) are not measured. In one object, the art of at most 4,096 symbol instances is measured
//! with its geometry effects, and the other instances in it are measured without them.
//! [`bounds_of_with`], [`oriented_bounds_with`] and [`anchor_bounds_with`] measure as these do and,
//! with [`Measure::glyphs`], take point type by the ink of its glyphs (Measure Type by Glyph Bounds).
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
@@ -74,6 +76,121 @@ pub fn anchor_bounds(selection: &Selection, doc: &Document, preview: bool) -> Op
union_opt(bounds_of(doc, &selection.whole(), preview), selection.anchors_box(doc))
}
/// How [`bounds_of_with`], [`oriented_bounds_with`] and [`anchor_bounds_with`] measure objects:
/// `preview` as in [`bounds_of`], and with `glyphs` (Preferences › Type › Measure Type by Glyph
/// Bounds), point type, horizontal or vertical, by the ink of its glyphs instead of its em box
/// ([`text_ink_in`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Measure {
pub preview: bool,
pub glyphs: bool,
}
impl Measure {
/// See [`by_glyphs`].
fn by_glyphs(self, drawn: &Drawn, n: &Node) -> bool {
self.glyphs
&& matches!(&n.kind, NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Point))
&& n.projection().is_none()
&& !(self.preview && drawn.draws_geometry(n))
}
}
/// Whether `m` measures `n` by the ink of its glyphs: point type, not in perspective, and with
/// `preview` only when no geometry effect changes what it draws.
pub fn by_glyphs(symbols: &SymbolBoxes, n: &Node, m: Measure) -> bool {
m.by_glyphs(&Drawn::new(symbols), n)
}
/// [`bounds_of`] measured by `m`.
pub fn bounds_of_with(doc: &Document, ids: &[NodeId], m: Measure) -> Option<Rect> {
if !m.glyphs {
return bounds_of(doc, ids, m.preview);
}
let symbols = doc.symbol_boxes();
let drawn = Drawn::new(&symbols);
if !ids.iter().filter_map(|id| doc.node(*id)).any(|n| m.by_glyphs(&drawn, n)) {
return bounds_of(doc, ids, m.preview);
}
ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, measured_in(&drawn, n, Affine::IDENTITY, m)))
}
/// [`oriented_bounds`] measured by `m`.
pub fn oriented_bounds_with(doc: &Document, ids: &[NodeId], m: Measure) -> Option<OrientedBox> {
if !m.glyphs {
return oriented_bounds(doc, ids, m.preview);
}
let symbols = doc.symbol_boxes();
let drawn = Drawn::new(&symbols);
if !ids.iter().filter_map(|id| doc.node(*id)).any(|n| m.by_glyphs(&drawn, n)) {
return oriented_bounds(doc, ids, m.preview);
}
let angle = doc.bbox_angle(ids);
let to_local = if angle == 0.0 { Affine::IDENTITY } else { OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse() };
let rect = ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, measured_in(&drawn, n, to_local, m)))?;
Some(OrientedBox { angle, rect })
}
/// [`anchor_bounds`] with the objects selected whole measured by `m`.
pub fn anchor_bounds_with(selection: &Selection, doc: &Document, m: Measure) -> Option<Rect> {
if selection.anchors.is_empty() {
return None;
}
union_opt(bounds_of_with(doc, &selection.whole(), m), selection.anchors_box(doc))
}
/// The box of `n` as `a` would place it, without changing it, measured by `m`: [`bounds_in`] with
/// `preview`, else [`Node::bounds_in`] of its geometric bounds, and point type by its glyphs as
/// [`Measure`] says.
pub fn bounds_in_with(symbols: &SymbolBoxes, n: &Node, a: Affine, m: Measure) -> Option<Rect> {
measured_in(&Drawn::new(symbols), n, a, m)
}
/// See [`bounds_in_with`].
fn measured_in(drawn: &Drawn, n: &Node, a: Affine, m: Measure) -> Option<Rect> {
if m.by_glyphs(drawn, n)
&& let NodeKind::Text(t) = &n.kind
&& let Some(r) = text_ink_in(t, a)
{
// The fills' and characters' strokes paint around the glyphs, as in `Node::visual_bounds`.
let o = if m.preview { n.appearance.outset().max(t.char_stroke_outset()) } else { 0.0 };
return Some(r.inflate(o, o));
}
match (m.preview, a == Affine::IDENTITY) {
(true, _) => drawn.next().bounds_in(n, a),
(false, true) => n.geometric_bounds_with(drawn.symbols),
(false, false) => n.bounds_in(drawn.symbols, a, false),
}
}
/// The box of the ink of type `t`'s glyphs with its text space mapped by `a * t.xf`: the cached
/// ink ([`vectorcraft_doc::TextObject::ink`]) when that map keeps a rectangle square, else the
/// glyph outlines and the underline and strikethrough bars mapped point by point
/// ([`vectorcraft_text::envelope_ink`]). `None` when nothing draws (no text, only spaces without
/// underline or strikethrough, or glyphs without outlines).
pub fn text_ink_in(t: &vectorcraft_doc::TextObject, a: Affine) -> Option<Rect> {
let m = a * t.xf;
let [p, q, r, s, _, _] = m.as_coeffs();
let square = (q.abs() <= 1e-12 && r.abs() <= 1e-12) || (p.abs() <= 1e-12 && s.abs() <= 1e-12);
if square {
let ink = match t.ink() {
Some(ink) => ink,
None => vectorcraft_text::envelope_ink(t, Affine::IDENTITY, None),
};
return ink.map(|i| m.transform_rect_bbox(i));
}
vectorcraft_text::envelope_ink(t, m, None)
}
/// The ink of type `t` laid out as `layout`, in text space: its glyphs
/// ([`vectorcraft_text::TextLayout::ink_bounds`]) and its underline and strikethrough bars
/// ([`vectorcraft_text::decorations`]), what [`text_ink_in`] measures. `None` when nothing draws.
pub fn laid_out_ink(layout: &vectorcraft_text::TextLayout, t: &vectorcraft_doc::TextObject) -> Option<Rect> {
vectorcraft_text::decorations(layout, vectorcraft_text::FontDb::global(), t)
.into_iter()
.fold(layout.ink_bounds(), |acc, (_, bar)| union_opt(acc, Some(bar.bounding_box())))
}
/// The visual bounds of `n` as drawn: those of its [`drawn_art`], else
/// [`Node::visual_bounds_with`].
pub fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
+32 -1
View File
@@ -657,7 +657,8 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
}
}
let runs = runs.into_iter().filter_map(|mut r| {
if r.member.is_none() {
// A face on its own needs a group only for the opacity or blending mode of its fill or stroke.
if r.member.is_none() && r.opacity >= 1.0 && r.blend == Default::default() {
return r.faces.pop().map(|face| (r.scopes, face));
}
let mut group = Node::group(n.id, r.faces);
@@ -949,6 +950,36 @@ mod tests {
assert_eq!(groups(&art), 2, "one group for each faded ring");
}
#[test]
fn a_member_whose_fill_or_stroke_paints_nothing_stays_invisible() {
let clear_fill = {
let mut n = (*member(3, 20.0)).clone();
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
f.opacity = 0.0;
}
n
};
let clear_stroke = {
let mut n = (*member(4, 20.0)).clone();
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0).into();
for item in n.appearance.items.iter_mut() {
if let AppearanceItem::Stroke(s) = item {
s.opacity = 0.0;
}
}
n
};
for clear in [clear_fill, clear_stroke] {
let art = revolve_art(&group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0), Arc::new(clear)])).unwrap();
let faces = faces_in_order(&art);
let alone = faces_in_order(&revolve_art(&group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)])).unwrap());
let opaque: Vec<_> = faces.iter().filter(|(_, s)| s.is_empty()).map(|(f, _)| f).collect();
assert_eq!(opaque, alone.iter().map(|(f, _)| f).collect::<Vec<_>>(), "only the opaque members' faces draw without transparency");
assert!(faces.len() > alone.len(), "the clear member's faces stay in the art");
assert!(faces.iter().all(|(_, s)| s.is_empty() || s.iter().any(|(o, _)| *o == 0.0)), "each clear face is drawn at 0% opacity");
}
}
fn star(id: u64) -> Arc<Node> {
let anchors = (0..1200)
.map(|i| {
+3
View File
@@ -40,6 +40,7 @@ vectorcraft-metafile = { workspace = true }
vectorcraft-plugins = { workspace = true }
vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
vectorcraft-js = { workspace = true }
# The commands' parameter schemas (schemas.rs).
vectorcraft-apischema = { workspace = true }
image = { workspace = true }
@@ -88,3 +89,5 @@ zune-jpegxl = { workspace = true }
zune-core = { workspace = true }
# The JavaScript parser's lexer, compared with the script checker's on the same scripts.
vectorcraft-js = { workspace = true }
# A bundled font's own glyph boxes, for the glyph bounds tests.
skrifa = { workspace = true }
+3
View File
@@ -2,6 +2,7 @@
pub mod actionset;
mod addons;
mod apiref;
pub(crate) mod appearance;
mod assets;
mod attributes;
@@ -384,6 +385,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(actionset::specs());
v.extend(importmatches::specs());
v.extend(addons::specs());
// Last, so the reference it writes ends with its own section.
v.extend(apiref::specs());
v
})
}
@@ -275,6 +275,7 @@ fn same_art(a: &NodeKind, b: &NodeKind) -> bool {
moved.xf = a.xf;
moved.cached_bounds = a.cached_bounds;
moved.cached_baselines.clone_from(&a.cached_baselines);
moved.cached_ink = a.cached_ink;
**a == moved
}
}
+461
View File
@@ -0,0 +1,461 @@
//! `engine.apiReference`: the command API reference in Markdown, written when it is asked for from
//! the parameter schemas of the built-in commands ([`crate::schemas`]). Each command that has a
//! schema gets a section, in registry order, with its parameters in the schema's order; a command
//! without a schema, a plug-in's included, is left out. The text depends only on the registry, so
//! every call in one build returns the same bytes.
//!
//! Text from a schema is escaped before it goes into a heading, a list item or a table cell: the
//! Markdown punctuation characters get a backslash (CommonMark 0.31.2, backslash escapes,
//! <https://spec.commonmark.org/0.31.2/#backslash-escapes>), so a description shows as plain text and
//! cannot end a table cell or make a link, an image or HTML; line breaks, tabs and other control
//! characters become spaces.
use serde_json::{Value, json};
use vectorcraft_apischema::{Bound, MAX_DEPTH, MAX_ID, Omitted, Param, ParamSchema, Type, Writes};
use super::{CommandSpec, Result, Session, always, cmd, command_specs};
use crate::EngineError;
const ID: &str = "engine.apiReference";
/// The largest reference the command returns, in bytes of UTF-8.
pub const MAX_BYTES: usize = 1 << 20;
static REFERENCE: ParamSchema = ParamSchema::new(
&[],
"the command API reference in Markdown, written from the built-in commands' parameter schemas: a section for each command that has one, in registry order",
)
.returns("{markdown: string}")
.writes(Writes::Nothing)
.since(2);
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
query "engine.apiReference",
"Command API Reference",
[],
None,
"{} the command API reference in Markdown, written from the built-in commands' parameter schemas: a section for each command that has one, in registry order → {markdown: string}",
always,
api_reference,
schema: &REFERENCE
)]
}
fn api_reference(_: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "markdown": reference(command_specs())? }))
}
const HEADER: &str = "# VectorCraft command API reference\n\nThis reference lists only the commands that have a parameter schema, in registry order. `engine.commands` lists every command, with its params text and whether it can run now.\n";
/// The reference for the commands of `specs` that have a schema, or an error when it would be
/// larger than [`MAX_BYTES`]; a reference that is too large is never cut short.
pub(crate) fn reference(specs: &[CommandSpec]) -> Result<String> {
let mut out = String::from(HEADER);
let mut sections = 0;
for spec in specs {
let Some(schema) = spec.schema else { continue };
let text = section(spec, schema);
if out.len() + text.len() > MAX_BYTES {
return Err(EngineError::Other(format!("`{ID}`: the reference would be larger than {MAX_BYTES} bytes")));
}
out.push_str(&text);
sections += 1;
}
if sections == 0 {
out.push_str("\nNo commands have parameter schemas.\n");
}
Ok(out)
}
/// One command's section: its heading, a list of what it is, and its parameters.
fn section(spec: &CommandSpec, schema: &ParamSchema) -> String {
let menu = if spec.menu.is_empty() { "None".to_string() } else { spec.menu.iter().map(|m| escape(m)).collect::<Vec<_>>().join(" › ") };
let mut s = format!("\n## {}\n\n", escape(spec.id));
s.push_str(&format!("- Label: {}\n", escape(spec.label)));
s.push_str(&format!("- Menu: {menu}\n"));
s.push_str(&format!("- API level: {}\n", schema.since));
s.push_str(&format!("- Deprecated: {}\n", level_text(schema.deprecated)));
s.push_str(&format!("- Writes: {}\n", writes_text(schema.writes)));
s.push_str(&format!("- Cost: {}\n", schema.cost));
s.push_str(&format!("- Summary: {}\n", text_or_none(schema.summary)));
s.push_str(&format!("- Returns: {}\n\n", text_or_none(schema.returns)));
if schema.params.is_empty() {
s.push_str("No parameters.\n");
return s;
}
s.push_str("| Parameter | Type | Range | Unit | Default | Required | Since | Deprecated | Description |\n");
s.push_str("|---|---|---|---|---|---|---|---|---|\n");
rows("", schema.params, 0, &mut s);
s
}
/// A table row for each of `params`, and after each one the rows of the members of an object it
/// holds: `edits[].name` for an object in a list, `options.size` for an object.
fn rows(prefix: &str, params: &[Param], depth: usize, out: &mut String) {
for p in params {
let name = if prefix.is_empty() { p.name.to_string() } else { format!("{prefix}.{}", p.name) };
let (range, unit) = range_and_unit(&p.ty);
out.push_str(&format!(
"| {} | {} | {range} | {unit} | {} | {} | {} | {} | {} |\n",
escape(&name),
type_text(&p.ty, depth),
default_text(p.omitted),
if p.required { "Yes" } else { "No" },
p.since,
level_text(p.deprecated),
description(p),
));
if depth < MAX_DEPTH
&& let Some((members, path)) = members(&p.ty, name, depth)
{
rows(&path, members, depth + 1, out);
}
}
}
/// The parameters of the object `ty` holds, directly or as the items of lists, with the path of
/// its members' prefix (`name`, `name[]`, `name[][]`).
fn members(ty: &Type, mut path: String, depth: usize) -> Option<(&'static [Param], String)> {
let mut ty = ty;
for _ in depth..MAX_DEPTH {
match ty {
Type::Object(params) => return Some((params, path)),
Type::Array(item) => {
path.push_str("[]");
ty = item;
}
_ => return None,
}
}
None
}
fn type_text(ty: &Type, depth: usize) -> String {
match ty {
Type::Number { .. } => "number".into(),
Type::Integer { .. } => "integer".into(),
Type::Boolean => "boolean".into(),
Type::String => "string".into(),
Type::Enum(names) => format!("one of {}", names.iter().map(|n| format!("\"{}\"", escape(n))).collect::<Vec<_>>().join(", ")),
Type::Id => "object id (integer)".into(),
Type::Ids => "list of object ids".into(),
Type::Color => escape("color (a string, [r, g, b] or an object)"),
Type::Point => escape("point ([x, y], two numbers)"),
Type::Object(_) => "object".into(),
Type::Array(_) if depth >= MAX_DEPTH => "list".into(),
Type::Array(item) => format!("list of {}", type_text(item, depth + 1)),
}
}
/// The range and unit cells of a value of type `ty`.
fn range_and_unit(ty: &Type) -> (String, String) {
let ends = |low: Option<String>, high: Option<String>| {
let both: Vec<String> = low.into_iter().chain(high).collect();
if both.is_empty() { "None".to_string() } else { both.join(", ") }
};
match ty {
Type::Number { min, max, unit } => {
let low = min.map(|b| match b {
Bound::Inclusive(a) => format!("at least {a}"),
Bound::Exclusive(a) => format!("more than {a}"),
});
let high = max.map(|b| match b {
Bound::Inclusive(a) => format!("at most {a}"),
Bound::Exclusive(a) => format!("less than {a}"),
});
(ends(low, high), unit.map_or("None".to_string(), escape))
}
Type::Integer { min, max } => (ends(min.map(|a| format!("at least {a}")), max.map(|a| format!("at most {a}"))), "None".into()),
Type::Id => (format!("at least 0, at most {MAX_ID}"), "None".into()),
_ => ("None".into(), "None".into()),
}
}
fn default_text(omitted: Omitted) -> String {
match omitted {
Omitted::Nothing => "None".into(),
Omitted::Value(json) => format!("{} (JSON)", escape(json)),
Omitted::Note(note) => format!("{} (worked out by the command)", escape(note)),
}
}
/// The parameter's doc text, and what the schema says about how its value is checked.
fn description(p: &Param) -> String {
let mut parts = Vec::new();
if !p.doc.is_empty() {
parts.push(escape(p.doc));
}
if p.null_omitted {
parts.push("null reads as omitted".to_string());
}
if p.lenient {
parts.push("lenient (a value of another type or out of range is passed to the command, not refused)".to_string());
}
if parts.is_empty() { "None".to_string() } else { parts.join("; ") }
}
fn level_text(level: Option<u32>) -> String {
level.map_or("None".to_string(), |l| format!("API level {l}"))
}
fn writes_text(writes: Writes) -> &'static str {
match writes {
Writes::Nothing => "nothing",
Writes::Session => "session",
Writes::Preferences => "preferences",
Writes::Document => "document",
}
}
fn text_or_none(text: &str) -> String {
if text.is_empty() { "None".to_string() } else { escape(text) }
}
/// The characters [`escape`] puts a backslash before.
const ESCAPED: &[char] = &['\\', '|', '[', ']', '<', '>', '`', '*', '_', '!', '&', '#', '~', ':', '@'];
/// `text` as plain Markdown text on one line: each of [`ESCAPED`] with a backslash before it, the
/// `.` after `www` too (so no autolink starts), and each control character as a space.
pub(crate) fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if c.is_control() {
out.push(' ');
continue;
}
let after_www = out.len().checked_sub(3).and_then(|at| out.as_bytes().get(at..)).is_some_and(|b| b.eq_ignore_ascii_case(b"www"));
if ESCAPED.contains(&c) || (c == '.' && after_www) {
out.push('\\');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn run(_: &mut Session, _: &Value) -> Result<Value> {
Ok(Value::Null)
}
fn spec(id: &'static str, menu: &'static [&'static str], schema: Option<&'static ParamSchema>) -> CommandSpec {
CommandSpec { id, label: "Label", menu, shortcut: None, params: "", enabled: always, run, journal: false, schema }
}
/// The cells of a table row as a GFM table reads them: split at each `|` that has no backslash
/// before it, with the outer pipes and the spaces around each cell taken off.
fn cells(row: &str) -> Vec<String> {
let inner = row.strip_prefix('|').and_then(|r| r.strip_suffix('|')).unwrap_or_else(|| panic!("not a table row: {row}"));
let (mut out, mut cell, mut chars) = (Vec::new(), String::new(), inner.chars());
while let Some(c) = chars.next() {
match c {
'\\' => {
cell.push(c);
cell.extend(chars.next());
}
'|' => out.push(std::mem::take(&mut cell).trim().to_string()),
_ => cell.push(c),
}
}
out.push(cell.trim().to_string());
out
}
/// What `text` shows as: each escaped character as itself. Panics on a Markdown punctuation
/// character without its backslash, or a backslash before a character that needs none.
fn literal(text: &str) -> String {
let (mut out, mut chars) = (String::new(), text.chars());
while let Some(c) = chars.next() {
assert!(!ESCAPED.contains(&c) || c == '\\', "`{c}` is not escaped in {text}");
if c == '\\' {
let next = chars.next().unwrap_or_else(|| panic!("a backslash ends {text}"));
assert!(ESCAPED.contains(&next) || next == '.', "`\\{next}` in {text}");
out.push(next);
} else {
out.push(c);
}
}
out
}
/// The shown cells of the row of parameter `name`.
fn row(markdown: &str, name: &str) -> Vec<String> {
let found = markdown.lines().filter(|l| l.starts_with('|')).find(|l| cells(l).first().map(|c| literal(c)).as_deref() == Some(name));
let row = found.unwrap_or_else(|| panic!("no row for {name} in\n{markdown}"));
let cells = cells(row);
assert_eq!(cells.len(), 9, "{row}");
cells.iter().map(|c| literal(c)).collect()
}
/// The text after `- {label}: ` in the section's list.
fn item(markdown: &str, label: &str) -> String {
let prefix = format!("- {label}: ");
let line = markdown.lines().find_map(|l| l.strip_prefix(prefix.as_str()));
literal(line.unwrap_or_else(|| panic!("no {label} in\n{markdown}")))
}
/// Two calls give the same bytes, with no document open, and the reference has one section
/// for each built-in command that has a schema, in registry order, ending with this command's.
#[test]
fn the_reference_lists_each_schema_once_in_registry_order_and_is_the_same_each_time() {
let mut s = Session::new();
let first = s.execute(ID, &json!({})).unwrap();
let second = s.execute(ID, &Value::Null).unwrap();
assert_eq!(first, second);
let markdown = first["markdown"].as_str().unwrap();
assert_eq!(markdown, reference(command_specs()).unwrap());
assert!(markdown.len() < MAX_BYTES / 10, "{} bytes", markdown.len());
let headings: Vec<String> = markdown.lines().filter_map(|l| l.strip_prefix("## ")).map(literal).collect();
let expected: Vec<&str> = command_specs().iter().filter(|c| c.schema.is_some()).map(|c| c.id).collect();
assert_eq!(headings, expected);
assert_eq!(headings.last().map(String::as_str), Some(ID));
assert!(expected.contains(&"view.saved.new") && !headings.iter().any(|h| h == "file.new"));
let new_view = &markdown[markdown.find("## view.saved.new").unwrap()..];
let names: Vec<String> = new_view
.lines()
.skip_while(|l| !l.starts_with("|---"))
.skip(1)
.take_while(|l| l.starts_with('|'))
.map(|l| literal(&cells(l)[0]))
.collect();
assert_eq!(names, ["name", "center", "zoom", "rotation"]);
let edit = &markdown[markdown.find("## view.saved.edit").unwrap()..];
assert_eq!(row(edit, "edits[].newName")[8], "null reads as omitted");
assert_eq!(row(edit, "newName")[8], "rename; null reads as omitted");
}
const INNER: &[Param] =
&[Param::new("size", Type::Integer { min: Some(1), max: Some(10) }), Param::new("mode", Type::Enum(&["a", "b"])).defaults_to("\"a\"")];
static FULL: ParamSchema = ParamSchema::new(
&[
Param::new("zoom", Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(64.0)), unit: Some("pt") })
.doc("1 = 100%"),
Param::new("name", Type::String).default_note("the first free name").null_omitted(),
Param::new("old", Type::Boolean).defaults_to("true").since(2).deprecated(3).lenient(),
Param::new("edits", Type::Array(&Type::Object(INNER))).optional(),
Param::new("ids", Type::Ids).optional(),
Param::new("at", Type::Point).optional(),
Param::new("color", Type::Color).optional(),
Param::new("id", Type::Id).optional(),
Param::new("ratio", Type::Number { min: Some(Bound::Inclusive(-1.5)), max: Some(Bound::Exclusive(2.0)), unit: None }).optional(),
],
"do the thing",
)
.returns("{done}")
.writes(Writes::Preferences)
.since(2)
.deprecated(4)
.cost(5);
/// Each field of a schema shows, nested members in their order under dotted names.
#[test]
fn every_field_of_a_schema_is_shown() {
let markdown = reference(&[spec("test.full", &["View", "Saved"], Some(&FULL))]).unwrap();
for (label, shown) in [
("Label", "Label"),
("Menu", "View › Saved"),
("API level", "2"),
("Deprecated", "API level 4"),
("Writes", "preferences"),
("Cost", "5"),
("Summary", "do the thing"),
("Returns", "{done}"),
] {
assert_eq!(item(&markdown, label), shown, "{label}");
}
let lenient = "lenient (a value of another type or out of range is passed to the command, not refused)";
let expected: [[&str; 9]; 11] = [
["zoom", "number", "more than 0, at most 64", "pt", "None", "Yes", "1", "None", "1 = 100%"],
["name", "string", "None", "None", "the first free name (worked out by the command)", "No", "1", "None", "null reads as omitted"],
["old", "boolean", "None", "None", "true (JSON)", "No", "2", "API level 3", lenient],
["edits", "list of object", "None", "None", "None", "No", "1", "None", "None"],
["edits[].size", "integer", "at least 1, at most 10", "None", "None", "Yes", "1", "None", "None"],
["edits[].mode", "one of \"a\", \"b\"", "None", "None", "\"a\" (JSON)", "No", "1", "None", "None"],
["ids", "list of object ids", "None", "None", "None", "No", "1", "None", "None"],
["at", "point ([x, y], two numbers)", "None", "None", "None", "No", "1", "None", "None"],
["color", "color (a string, [r, g, b] or an object)", "None", "None", "None", "No", "1", "None", "None"],
["id", "object id (integer)", "at least 0, at most 9007199254740991", "None", "None", "No", "1", "None", "None"],
["ratio", "number", "at least -1.5, less than 2", "None", "None", "No", "1", "None", "None"],
];
let names: Vec<String> =
markdown.lines().filter(|l| l.starts_with("| ") && !l.starts_with("| Parameter")).map(|l| literal(&cells(l)[0])).collect();
assert_eq!(names, expected.map(|r| r[0]));
for want in expected {
assert_eq!(row(&markdown, want[0]), want, "{}", want[0]);
}
}
const HOSTILE: &str = "a | b [link](https://evil.example) <script>x</script> `code` \\ *em* _u_ ![img](x) &amp; ~~s~~ # h www.example.com me@example.com\r\nnext\tline";
static ESCAPES: ParamSchema =
ParamSchema::new(&[Param::new("a|b", Type::String).doc(HOSTILE).defaults_to("\"<b>|</b>\"")], HOSTILE).returns(HOSTILE);
/// Markdown in a schema's text shows as plain text: a description stays in its own cell of a
/// nine-cell row, every punctuation character is escaped, and line breaks and tabs are spaces.
#[test]
fn markdown_in_schema_text_stays_literal_in_its_cell() {
let markdown = reference(&[spec("test.escapes", &["A|B", "[x](y)"], Some(&ESCAPES))]).unwrap();
let shown = HOSTILE.replace(['\r', '\n', '\t'], " ");
let cells = row(&markdown, "a|b");
assert_eq!(cells[4], "\"<b>|</b>\" (JSON)");
assert_eq!(cells[8], shown);
assert_eq!(item(&markdown, "Summary"), shown);
assert_eq!(item(&markdown, "Returns"), shown);
assert_eq!(item(&markdown, "Menu"), "A|B › [x](y)");
assert!(markdown.contains("www\\.example"), "{markdown}");
let table: Vec<&str> = markdown.lines().filter(|l| l.starts_with('|')).collect();
assert_eq!(table.len(), 3, "the header, the delimiter row and one row: {table:?}");
assert!(!markdown.contains('\r') && !markdown.contains('\t'));
}
/// A registry with no schema gives the header and a line that says so; a command without a
/// schema adds nothing.
#[test]
fn a_registry_without_schemas_gives_a_document_that_says_so() {
let empty = format!("{HEADER}\nNo commands have parameter schemas.\n");
assert_eq!(reference(&[]).unwrap(), empty);
assert_eq!(reference(&[spec("test.none", &["View"], None)]).unwrap(), empty);
let one = reference(&[spec("test.none", &[], None), spec("test.full", &[], Some(&FULL)), spec("test.other", &[], None)]).unwrap();
assert_eq!(one, reference(&[spec("test.full", &[], Some(&FULL))]).unwrap());
}
/// A reference of exactly [`MAX_BYTES`] is returned; one byte more is an error naming the
/// command, with no part of the reference.
#[test]
fn a_reference_past_the_limit_is_an_error() {
let with = |summary: String| -> Result<String> {
let schema: &'static ParamSchema = Box::leak(Box::new(ParamSchema::new(&[], Box::leak(summary.into_boxed_str()))));
reference(&[spec("test.big", &[], Some(schema))])
};
let base = with("x".into()).unwrap().len();
let fits = with("x".repeat(MAX_BYTES - base + 1)).unwrap();
assert_eq!(fits.len(), MAX_BYTES);
let e = with("x".repeat(MAX_BYTES - base + 2)).unwrap_err().to_string();
assert!(e.contains(ID) && e.contains("larger than 1048576 bytes"), "{e}");
assert!(e.len() < 200, "{e}");
}
/// An unknown key is refused with a parameter error naming it, and the command needs no
/// document.
#[test]
fn an_unknown_parameter_is_refused_naming_it() {
let mut s = Session::new();
let e = s.execute(ID, &json!({"prefix": "view."})).unwrap_err().to_string();
assert!(e.contains(ID) && e.contains("`prefix`"), "{e}");
assert!(s.execute(ID, &json!([])).is_err());
assert!(s.active().is_none());
assert!(s.execute(ID, &json!({})).is_ok());
}
#[test]
fn escape_marks_each_punctuation_character_and_flattens_control_characters() {
assert_eq!(escape("a|b"), "a\\|b");
assert_eq!(escape("WWW.x and w.x"), "WWW\\.x and w.x");
assert_eq!(escape("é\u{7}\u{85}z"), "é z");
assert_eq!(escape("1.5 > 1"), "1.5 \\> 1");
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Assets",
[],
None,
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order",
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included, with the clips of the groups and layers that hold it), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order",
has_doc,
list
),
+1 -1
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export CSS",
[],
None,
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
has_doc,
export
),
@@ -635,6 +635,7 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
}
};
st.change_unsaved(clear);
st.history.rewrites += 1;
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
let saved = Arc::ptr_eq(&e.doc, &st.saved_doc);
clear(&mut e.doc);
+2
View File
@@ -184,6 +184,8 @@ mod tests_batch;
#[cfg(test)]
mod tests_screens;
#[cfg(test)]
mod tests_selexport;
#[cfg(test)]
mod tests_text;
#[cfg(test)]
+3 -3
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
has_doc,
export::serialize
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path",
has_doc,
export::export
),
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Selection…",
["File"],
None,
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
has_selection,
export::export_selection
),
+83 -32
View File
@@ -94,27 +94,63 @@ pub fn export_source<'a>(st: &'a DocState, p: &Value) -> Result<Cow<'a, Document
return Err(bad("document.export", "select something to export (or turn off selectedOnly)"));
}
let mut d = Document::clone(&st.doc);
d.layers = st.doc.layers.iter().filter_map(|l| selected_part(l, &keep)).map(Arc::new).collect();
d.layers = enclosed(&st.doc, &keep, false);
Ok(Cow::Owned(d))
}
/// `n` with only the parts in `keep` (whole, with everything inside them), `None` when none is.
fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
/// The layers of `doc` with only the objects in `keep` (whole, with everything inside them), each
/// in the layers, groups and compound paths that hold it. Those keep what they apply to the object
/// on the canvas: their clipping paths, opacity masks, effects, opacity and blend modes. An object
/// of a blend, envelope, repeat or intertwine goes in on its own, without the art made from it. With
/// `shown`, the layers and groups that hold the objects are visible, and those layers print and are
/// not template layers.
fn enclosed(doc: &Document, keep: &HashSet<NodeId>, shown: bool) -> Vec<Arc<Node>> {
let holders: HashSet<NodeId> = keep.iter().filter_map(|id| doc.ancestry(*id)).flatten().filter(|id| !keep.contains(id)).collect();
let mut out = vec![];
for l in &doc.layers {
selected_part(l, keep, &holders, shown, &mut out);
}
out
}
/// The parts of `n` in `keep` (whole, with everything inside them), added to `out`: `n` itself, or
/// `n` with only those parts when it is one of their `holders`, made visible, printable and not a
/// template with `shown`.
fn selected_part(n: &Arc<Node>, keep: &HashSet<NodeId>, holders: &HashSet<NodeId>, shown: bool, out: &mut Vec<Arc<Node>>) {
if keep.contains(&n.id) {
return Some(n.clone());
out.push(n.clone());
return;
}
let Some(children) = n.children().filter(|_| holders.contains(&n.id)) else { return };
let mut kept = vec![];
for c in children {
selected_part(c, keep, holders, shown, &mut kept);
}
if !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. }) {
out.extend(kept);
return;
}
let children = n.children()?;
let mut kept: Vec<Arc<Node>> = children.iter().filter_map(|c| selected_part(c, keep).map(Arc::new)).collect();
if kept.is_empty() {
return None;
return;
}
let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
if let Some(first) = children.first().filter(|f| clip && kept.first().is_none_or(|k| k.id != f.id)) {
// A clipping group or layer keeps its clipping path; a Shaper group its source art, which
// draws nothing.
let first_stays = n.clips() || n.extras().shaper.is_some();
if let Some(first) = children.first().filter(|f| first_stays && kept.first().is_none_or(|k| k.id != f.id)) {
kept.insert(0, first.clone());
}
let mut out = n.clone();
*out.children_mut()? = kept;
Some(out)
let mut part = Node::clone(n);
if let Some(ch) = part.children_mut() {
*ch = kept;
}
if shown {
part.visible = true;
if let NodeKind::Layer { template, printable, .. } = &mut part.kind {
*template = false;
*printable = true;
}
}
out.push(Arc::new(part));
}
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
@@ -202,7 +238,7 @@ fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Va
/// with `selectionOnly` the selected objects alone.
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(Cow<'a, Document>, bool)> {
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
let doc = selection(s, cmd)?.0;
let doc = selection(s, cmd, true)?.0;
let display_doc_title = super::has_own_title(&doc);
return Ok((Cow::Owned(doc), display_doc_title));
}
@@ -212,11 +248,21 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C
Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title))
}
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
/// visual bounds.
fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> {
/// The selected objects (not on template layers) in the layers and groups that hold them
/// ([`isolated`]), with one artboard: their crop. With `text`, the objects alone on one layer, so a
/// text export holds the type of the selected objects and not that of a clipping path around them.
fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> {
let ids = selected_artwork(s)?;
isolated(&s.doc()?.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
let doc = &s.doc()?.doc;
let (mut d, bounds) = isolated(doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))?;
if text {
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
}
d.layers = vec![Arc::new(layer)];
}
Ok((d, bounds))
}
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
@@ -231,25 +277,30 @@ pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> {
Ok(ids.into_iter().filter(|id| !on_template(*id)).collect())
}
/// The crop of an export of objects `ids` of `doc` alone: the bounds the renderer measures for what
/// it draws for them, their effects included ([`vectorcraft_render::effect_bounds`]; symbol
/// instances are measured by their art), `None` when they have none.
/// The crop of an export of objects `ids` of `doc` alone ([`isolated`]): the bounds the renderer
/// measures for what it draws for them, their effects included ([`vectorcraft_render::effect_bounds`];
/// symbol instances are measured by their art), with what the layers and groups that hold them
/// apply (a clipping group or layer is measured by its clip), `None` when they have none.
pub(crate) fn export_crop(doc: &Document, ids: &[NodeId]) -> Option<vectorcraft_geom::Rect> {
let symbols = doc.symbol_boxes();
ids.iter().filter_map(|id| vectorcraft_render::effect_bounds(&symbols, doc.node(*id)?)).reduce(|a, b| a.union(b))
isolated_art(doc, ids).1
}
/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard
/// named `name`: their [`export_crop`] (`None` when they have none).
/// The layers of [`isolated`] and their crop.
fn isolated_art(doc: &Document, ids: &[NodeId]) -> (Vec<Arc<Node>>, Option<vectorcraft_geom::Rect>) {
let keep: HashSet<NodeId> = ids.iter().copied().collect();
let layers = enclosed(doc, &keep, true);
let symbols = doc.symbol_boxes();
let bounds = layers.iter().filter_map(|l| vectorcraft_render::effect_bounds(&symbols, l)).reduce(|a, b| a.union(b));
(layers, bounds)
}
/// Objects `ids` of `doc` alone, each in the layers and groups that hold it ([`enclosed`], made
/// visible), with one artboard named `name`: their [`export_crop`] (`None` when they have none).
pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
let bounds = export_crop(doc, ids)?;
let nodes: Vec<Arc<Node>> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
let (layers, bounds) = isolated_art(doc, ids);
let bounds = bounds?;
let mut d = single_artboard(doc, bounds, name);
let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = nodes;
}
d.layers = vec![Arc::new(layer)];
d.layers = layers;
Some((d, bounds))
}
@@ -265,7 +316,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable(C, str_param(p, "format"), path)?;
let expanded = expand_presets(s, C, p)?;
let p = with_black_output(s, &expanded);
let (d, bounds) = selection(s, C)?;
let (d, bounds) = selection(s, C, f.id == "txt")?;
// The encoder's warnings (options accepted but not applied, features approximated or left
// out), as document.export reports them.
let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
@@ -0,0 +1,375 @@
//! Exports of objects alone (Export Selection, Export for Screens by selection or by layer, Asset
//! Export, a CSS rule's picture and `selectedOnly`) draw the objects as the canvas draws them: with
//! the clipping paths, opacity masks, effects, opacity and blend modes of the layers and groups that
//! hold them.
use serde_json::{Value, json};
use vectorcraft_geom::Rect;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
s
}
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> u64 {
v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}"))
}
/// A red 300 pt square at (100, 100) clipped by a 200 pt circle at (150, 150): (square, clip group).
fn clipped(s: &mut Session) -> (u64, u64) {
let square = id(&run(s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300})));
run(s, "paint.setFill", json!({"color": "#ff0000"}));
let circle = id(&run(s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 200, "height": 200})));
let group = id(&run(s, "object.clippingMask.make", json!({"ids": [circle, square]})));
(square, group)
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap_or_else(|| panic!("no dataBase64 in {v}"))).unwrap()
}
fn rect_of(v: &Value) -> Rect {
let n = |i: usize| v[i].as_f64().unwrap_or_else(|| panic!("bad bounds {v}"));
Rect::new(n(0), n(1), n(0) + n(2), n(1) + n(3))
}
/// What the canvas draws of the whole document over `r`, at 1 px per point.
fn canvas(s: &Session, r: Rect) -> image::RgbaImage {
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, r, 1.0, false);
image::load_from_memory(&img.to_png().unwrap()).unwrap().to_rgba8()
}
/// The pixels of `png` that differ from `want` by more than 64 in a channel (anti-aliased edges
/// differ by less), and every pixel when the sizes differ.
fn differing(png: &[u8], want: &image::RgbaImage) -> usize {
let got = image::load_from_memory(png).unwrap().to_rgba8();
if got.dimensions() != want.dimensions() {
return (want.width() * want.height()) as usize;
}
got.pixels().zip(want.pixels()).filter(|(a, b)| a.0.iter().zip(b.0.iter()).any(|(x, y)| x.abs_diff(*y) > 64)).count()
}
/// Export Selection of what is selected, as a PNG, matches the canvas over its crop: fewer than 200
/// pixels differ (anti-aliased edges), or 200 times `edges` with [`selection_png_matches_canvas_with`].
fn selection_png_matches_canvas(s: &mut Session, want: Rect, what: &str) {
selection_png_matches_canvas_with(s, want, what, 1);
}
fn selection_png_matches_canvas_with(s: &mut Session, want: Rect, what: &str, edges: usize) {
let r = run(s, "document.exportSelection", json!({"format": "png", "scale": 1}));
let got = rect_of(&r["bounds"]);
assert!((got.x0 - want.x0).abs() < 1.5 && (got.y0 - want.y0).abs() < 1.5, "{what}: crop {got:?}, want {want:?}");
assert!((got.width() - want.width()).abs() < 3.0 && (got.height() - want.height()).abs() < 3.0, "{what}: crop {got:?}, want {want:?}");
let off = differing(&b64(&r), &canvas(s, got));
assert!(off < 200 * edges, "{what}: {off} pixels differ from the canvas");
}
#[test]
fn export_selection_of_a_clipped_object_keeps_its_clip() {
let mut s = session();
let (square, _) = clipped(&mut s);
run(&mut s, "select.set", json!({"ids": [square]}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "the square alone");
let svg = b64(&run(&mut s, "document.exportSelection", json!({"format": "svg"})));
let text = String::from_utf8_lossy(&svg);
assert!(text.contains("<clipPath") && text.contains("clip-path"), "the SVG clips the square: {text}");
// Opened again, the SVG draws what the canvas draws.
let mut t = Session::new();
run(&mut t, "document.open", json!({"name": "sel.svg", "dataBase64": vectorcraft_format::base64_encode(&svg)}));
let board = t.doc().unwrap().doc.artboards[0].rect;
let reopened = canvas(&t, board);
let mut png = vec![];
reopened.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let off = differing(&png, &canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0)));
assert!(off < 200, "the reopened SVG: {off} pixels differ from the canvas");
}
#[test]
fn export_selection_of_a_clipping_layer_keeps_the_layer_clip() {
let mut s = session();
let layer = id(&run(&mut s, "layer.new", json!({"name": "L"})));
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 200, "height": 200}));
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer}));
run(&mut s, "select.all", json!({}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "a clipping layer's objects");
}
#[test]
fn export_selection_keeps_the_opacity_effects_and_mask_of_the_groups_around_it() {
let mut s = session();
let (square, group) = clipped(&mut s);
let other = id(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})));
let outer = id(&run(&mut s, "object.group", json!({"ids": [group, other]})));
run(&mut s, "transparency.set", json!({"ids": [outer], "opacity": 50}));
run(&mut s, "select.set", json!({"ids": [square]}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "inside a group at 50%");
// A drop shadow on the clip group reaches past its clip.
run(&mut s, "effect.apply", json!({"ids": [group], "effect": "stylize.dropShadow"}));
let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1}));
let got = rect_of(&r["bounds"]);
assert!(got.x1 > 352.0 && got.y1 > 352.0, "the crop takes in the clip group's shadow: {got:?}");
assert!(differing(&b64(&r), &canvas(&s, got)) < 200, "with the shadow");
run(&mut s, "edit.undo", json!({}));
// An opacity mask on the clip group.
let mask = id(&run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300})));
run(
&mut s,
"paint.setFill",
json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}}),
);
run(&mut s, "transparency.makeOpacityMask", json!({"ids": [mask, group]}));
run(&mut s, "select.set", json!({"ids": [square]}));
let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1}));
let got = rect_of(&r["bounds"]);
assert!(differing(&b64(&r), &canvas(&s, got)) < 200, "with an opacity mask on the clip group");
}
#[test]
fn export_selection_across_clip_groups_and_of_whole_ones() {
let mut s = session();
let (a, ga) = clipped(&mut s);
let b = id(&run(&mut s, "shape.rectangle", json!({"x": 400, "y": 400, "width": 90, "height": 90})));
let c = id(&run(&mut s, "shape.rectangle", json!({"x": 420, "y": 420, "width": 50, "height": 50})));
let gb = id(&run(&mut s, "object.clippingMask.make", json!({"ids": [c, b]})));
// A member of each clip group: each keeps its own clip.
run(&mut s, "select.set", json!({"ids": [a, b]}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 470.0, 470.0), "a member of each of two clip groups");
// The clip groups themselves.
run(&mut s, "select.set", json!({"ids": [ga, gb]}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 470.0, 470.0), "two clip groups");
// Nested clip groups: the inner group's member is clipped by both, cropped to the outer clip.
let strip = id(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 200, "width": 500, "height": 100})));
run(&mut s, "object.clippingMask.make", json!({"ids": [strip, ga]}));
run(&mut s, "select.set", json!({"ids": [a]}));
selection_png_matches_canvas(&mut s, Rect::new(0.0, 200.0, 500.0, 300.0), "a member of nested clip groups");
}
#[test]
fn a_selected_clipping_path_and_a_blend_object_export_on_their_own() {
let mut s = session();
let (square, group) = clipped(&mut s);
let circle = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[0].id.0;
assert_ne!(circle, square);
// The clipping path alone: the clip group's other art stays out.
run(&mut s, "select.set", json!({"ids": [circle]}));
let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1}));
let img = image::load_from_memory(&b64(&r)).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (200, 200), "cropped to the clipping path");
assert!(!img.pixels().any(|p| p[0] > 200 && p[1] < 80 && p[3] > 0), "the clipped square is left out");
// An object of a blend goes in on its own, without the blend's steps.
let mut s = session();
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let a = id(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 60, "height": 60})));
let b = id(&run(&mut s, "shape.rectangle", json!({"x": 400, "y": 400, "width": 60, "height": 60})));
run(&mut s, "select.set", json!({"ids": [a, b]}));
run(&mut s, "object.blend.make", json!({}));
run(&mut s, "select.set", json!({"ids": [a]}));
let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1}));
let got = rect_of(&r["bounds"]);
assert!(got.x1 < 90.0 && got.y1 < 90.0, "the blend object's own bounds: {got:?}");
let img = image::load_from_memory(&b64(&r)).unwrap().to_rgba8();
let centre = img.get_pixel(img.width() / 2, img.height() / 2);
assert!(centre[0] > 200 && centre[3] > 200, "the object is drawn: {centre:?}");
}
#[test]
fn a_css_picture_of_a_clipped_object_is_clipped() {
let mut s = session();
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let star = id(&run(&mut s, "shape.star", json!({"cx": 250, "cy": 250, "radius1": 150, "radius2": 70, "points": 5})));
let circle = id(&run(&mut s, "shape.ellipse", json!({"x": 200, "y": 200, "width": 100, "height": 100})));
run(&mut s, "object.clippingMask.make", json!({"ids": [circle, star]}));
let r = run(&mut s, "css.export", json!({"ids": [star], "rasterize": true, "unnamed": true}));
let image = r["images"].as_array().and_then(|i| i.first()).unwrap_or_else(|| panic!("no picture: {r}"));
let png = b64(image);
let size = image::load_from_memory(&png).unwrap().to_rgba8().dimensions();
assert_eq!(size, (100, 100), "the picture is cropped to the clip");
assert!(differing(&png, &canvas(&s, Rect::new(200.0, 200.0, 300.0, 300.0))) < 100, "the picture matches the canvas");
}
#[test]
fn an_asset_on_a_hidden_layer_is_drawn_clipped() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id.0;
let (square, _) = clipped(&mut s);
let asset = run(&mut s, "assets.add", json!({"ids": [square]}))["assets"][0].as_u64().unwrap();
run(&mut s, "layer.setProps", json!({"id": layer, "visible": false}));
let r = run(&mut s, "assets.export", json!({"assets": [asset], "formats": [{"format": "png", "scale": 1}]}));
let img = image::load_from_memory(&b64(&r["files"][0])).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (200, 200), "cropped to the clip");
let (centre, corner) = (img.get_pixel(100, 100), img.get_pixel(3, 3));
assert!(centre[0] > 200 && centre[3] > 200, "the square is drawn: {centre:?}");
assert_eq!(corner[3], 0, "outside the circle stays clear: {corner:?}");
}
#[test]
fn export_for_screens_and_assets_by_selection_keep_the_clip() {
let mut s = session();
let (square, _) = clipped(&mut s);
run(&mut s, "select.set", json!({"ids": [square]}));
let r = run(&mut s, "document.exportForScreens", json!({"selection": "one", "formats": [{"format": "png", "scale": 1}, {"format": "svg"}]}));
let want = canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0));
let png = r["files"].as_array().unwrap().iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(".png"))).unwrap();
assert!(differing(&b64(png), &want) < 200, "Export for Screens by selection: {}", png["name"]);
let svg = r["files"].as_array().unwrap().iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(".svg"))).unwrap();
let text = String::from_utf8(b64(svg)).unwrap();
assert!(text.contains("<clipPath"), "the SVG clips the square: {text}");
let asset = run(&mut s, "assets.add", json!({"ids": [square]}))["assets"][0].as_u64().unwrap();
let list = run(&mut s, "assets.list", json!({}));
let bounds = rect_of(&list["assets"][0]["bounds"]);
assert!((bounds.width() - 200.0).abs() < 3.0, "the asset's crop is its clip: {bounds:?}");
let r = run(&mut s, "assets.export", json!({"assets": [asset], "formats": [{"format": "png", "scale": 1}]}));
assert!(differing(&b64(&r["files"][0]), &want) < 200, "Asset Export: {}", r["files"][0]["name"]);
}
/// The red pixels of `png`.
fn red(png: &[u8]) -> usize {
image::load_from_memory(png).unwrap().to_rgba8().pixels().filter(|p| p[0] > 200 && p[1] < 80 && p[3] > 200).count()
}
#[test]
fn a_selection_on_a_non_printing_layer_exports_to_pdf() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id.0;
let (square, _) = clipped(&mut s);
run(&mut s, "layer.setProps", json!({"id": layer, "printable": false}));
run(&mut s, "select.set", json!({"ids": [square]}));
let pdf = b64(&run(&mut s, "document.exportSelection", json!({"format": "pdf", "createLayers": false, "preserveEditing": false})));
let mut t = Session::new();
run(&mut t, "document.open", json!({"name": "sel.pdf", "dataBase64": vectorcraft_format::base64_encode(&pdf)}));
let page = t.doc().unwrap().doc.artboards[0].rect;
let mut png = vec![];
canvas(&t, page).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert!(red(&png) > 20_000, "the PDF draws the square: {} red pixels", red(&png));
assert!(differing(&png, &canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0))) < 300, "the PDF draws what the canvas draws");
}
#[test]
fn export_for_screens_of_layers_draws_them_as_the_canvas_does() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id.0;
clipped(&mut s);
// A layer at 50% opacity: its art is drawn at 50%.
run(&mut s, "transparency.set", json!({"ids": [layer], "opacity": 50}));
let r = run(&mut s, "document.exportForScreens", json!({"layers": [layer], "formats": [{"format": "png", "scale": 1}]}));
let png = b64(&r["files"][0]);
assert!(differing(&png, &canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0))) < 200, "the layer's opacity applies");
// A template layer's art is still drawn.
run(&mut s, "transparency.set", json!({"ids": [layer], "opacity": 100}));
run(&mut s, "layer.setProps", json!({"id": layer, "template": true}));
let r = run(&mut s, "document.exportForScreens", json!({"layers": [layer], "formats": [{"format": "png", "scale": 1}, {"format": "svg"}]}));
let files = r["files"].as_array().unwrap();
let file = |ext: &str| b64(files.iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(ext))).unwrap());
assert!(red(&file(".png")) > 20_000, "the template layer's art is drawn: {} red pixels", red(&file(".png")));
assert!(String::from_utf8(file(".svg")).unwrap().contains("<clipPath"), "the SVG holds the clipped art");
}
#[test]
fn a_text_export_of_the_selection_leaves_out_a_type_clipping_path() {
let mut s = session();
// The front object, the type "Clip", becomes the clipping path.
let inner = id(&run(&mut s, "text.create", json!({"x": 100, "y": 110, "text": "Inner"})));
let clip = id(&run(&mut s, "text.create", json!({"x": 100, "y": 100, "text": "Clip"})));
run(&mut s, "object.clippingMask.make", json!({"ids": [clip, inner]}));
let mut first = None;
s.doc().unwrap().doc.layers[0].walk(&mut |n| {
if matches!(n.kind, vectorcraft_doc::NodeKind::Group { clip: true, .. }) {
first = n.children().and_then(|c| c.first()).map(|c| c.id.0);
}
});
assert_eq!(first, Some(clip), "the type is the clipping path");
run(&mut s, "select.set", json!({"ids": [inner]}));
let txt = b64(&run(&mut s, "document.export", json!({"format": "txt", "selectionOnly": true})));
assert_eq!(String::from_utf8(txt).unwrap(), "Inner\n");
let txt = b64(&run(&mut s, "document.exportSelection", json!({"format": "txt"})));
assert_eq!(String::from_utf8(txt).unwrap(), "Inner\n");
}
#[test]
fn deep_clip_groups_symbols_and_large_selections_keep_their_clips() {
// Five clip groups inside each other, each clip a little smaller.
let mut s = session();
let (square, mut group) = clipped(&mut s);
for i in 1..5 {
let inset = 10.0 * f64::from(i);
let clip = id(&run(&mut s, "shape.rectangle", json!({"x": 150.0 + inset, "y": 150.0 + inset, "width": 200.0 - 2.0 * inset, "height": 400})));
group = id(&run(&mut s, "object.clippingMask.make", json!({"ids": [clip, group]})));
}
run(&mut s, "select.set", json!({"ids": [square]}));
selection_png_matches_canvas(&mut s, Rect::new(190.0, 190.0, 310.0, 590.0), "a member of five nested clip groups");
// A symbol instance in a clip group.
let mut s = session();
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let art = id(&run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300})));
run(&mut s, "select.set", json!({"ids": [art]}));
let instance = id(&run(&mut s, "symbol.new", json!({"name": "Red"})));
let circle = id(&run(&mut s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 200, "height": 200})));
run(&mut s, "object.clippingMask.make", json!({"ids": [circle, instance]}));
run(&mut s, "select.set", json!({"ids": [instance]}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "a symbol instance in a clip group");
// 200 clip groups, a member of each selected.
let mut s = session();
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let mut members = vec![];
for i in 0..200 {
let (x, y) = (f64::from(i % 20) * 25.0, f64::from(i / 20) * 25.0);
let m = id(&run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": 20, "height": 20})));
let c = id(&run(&mut s, "shape.ellipse", json!({"x": x, "y": y, "width": 20, "height": 20})));
run(&mut s, "object.clippingMask.make", json!({"ids": [c, m]}));
members.push(m);
}
run(&mut s, "select.set", json!({"ids": members}));
// The circles' anti-aliased edges differ by up to about 30 pixels each; without their clips the
// squares' corners differ by 86 pixels each.
selection_png_matches_canvas_with(&mut s, Rect::new(0.0, 0.0, 495.0, 245.0), "members of 200 clip groups", 40);
}
#[test]
fn an_asset_in_a_hidden_or_locked_clip_group_is_drawn_clipped() {
for change in ["object.hide", "object.lock"] {
let mut s = session();
let (square, group) = clipped(&mut s);
let asset = run(&mut s, "assets.add", json!({"ids": [square]}))["assets"][0].as_u64().unwrap();
let want = canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0));
run(&mut s, "select.set", json!({"ids": [group]}));
run(&mut s, change, json!({}));
let r = run(&mut s, "assets.export", json!({"assets": [asset], "formats": [{"format": "png", "scale": 1}]}));
assert!(differing(&b64(&r["files"][0]), &want) < 200, "{change}: the asset draws as the canvas did");
}
}
#[test]
fn selected_only_keeps_holders_and_takes_a_blend_object_alone() {
let mut s = session();
let (square, _) = clipped(&mut s);
run(&mut s, "select.set", json!({"ids": [square]}));
let r = run(&mut s, "document.export", json!({"format": "png", "scale": 1, "selectedOnly": true}));
let board = s.doc().unwrap().doc.artboards[0].rect;
assert!(differing(&b64(&r), &canvas(&s, board)) < 200, "selectedOnly keeps the clip");
// An object of a blend is written on its own.
let mut s = session();
let a = id(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 60, "height": 60})));
let b = id(&run(&mut s, "shape.rectangle", json!({"x": 400, "y": 400, "width": 60, "height": 60})));
run(&mut s, "select.set", json!({"ids": [a, b]}));
run(&mut s, "object.blend.make", json!({}));
run(&mut s, "select.set", json!({"ids": [a]}));
let svg = run(&mut s, "document.serialize", json!({"format": "svg", "selectedOnly": true}))["text"].as_str().unwrap().to_string();
let mut t = Session::new();
run(&mut t, "document.open", json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())}));
let mut paths = 0;
for l in &t.doc().unwrap().doc.layers {
l.walk(&mut |n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
}
assert_eq!(paths, 1, "the blend object alone, without the steps: {svg}");
}
+12 -5
View File
@@ -6,16 +6,23 @@ use super::{
use vectorcraft_doc::{NodeKind, TextKind};
impl Session {
/// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure:
/// with Use Preview Bounds on, visual bounds as drawn (strokes and geometry effects included,
/// see [`effects::preview`]), else geometric bounds.
/// The bounds of `ids` the Transform panel and the bounding box measure: with Use Preview
/// Bounds on, visual bounds as drawn (strokes and geometry effects included, see
/// [`effects::preview`]), else geometric bounds; with Measure Type by Glyph Bounds on, point
/// type by the ink of its glyphs ([`Session::bounds_measure`]).
pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> {
effects::preview::bounds_of(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds)
effects::preview::bounds_of_with(&self.doc().ok()?.doc, ids, self.bounds_measure())
}
/// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding
/// box, see [`Document::oriented_bounds`]).
pub fn transform_box(&self, ids: &[NodeId]) -> Option<OrientedBox> {
effects::preview::oriented_bounds(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds)
effects::preview::oriented_bounds_with(&self.doc().ok()?.doc, ids, self.bounds_measure())
}
/// How the bounding box, the Transform panel and `object.setBounds` measure objects: Use
/// Preview Bounds, and point type by its glyphs with Measure Type by Glyph Bounds
/// (`typeGlyphBounds`).
pub fn bounds_measure(&self) -> effects::preview::Measure {
effects::preview::Measure { preview: self.prefs.use_preview_bounds, glyphs: self.prefs.type_glyph_bounds }
}
}
+23 -12
View File
@@ -59,24 +59,31 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchor
let st = s.doc()?;
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
check_switches(p, &["proportional"], "object.setBounds")?;
let how = s.bounds_measure();
if anchors
&& transforms_objects(p)
&& !bool_or(p, "copy", false)
&& let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds)
&& let Some(b) = effects::preview::anchor_bounds_with(&st.selection, &st.doc, how)
{
return Ok(Bounds::Anchors(ids, b));
}
let d = &s.doc()?.doc;
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
// the math below runs in its frame.
let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
// the math below runs in its frame. With Measure Type by Glyph Bounds, point type is measured
// by its glyphs' ink, which a transform of the type moves and scales with it.
let geometric = effects::preview::Measure { preview: false, ..how };
let frame = effects::preview::oriented_bounds_with(d, &ids, geometric).ok_or_else(|| EngineError::Other("no bounds".into()))?;
let g = frame.rect;
let symbols = d.symbol_boxes();
let by_glyphs = how.glyphs && ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::by_glyphs(&symbols, n, how));
// With Use Preview Bounds the values measure the visual box as drawn, whose margins around the
// geometric box (left, top, right, bottom) scale with the strokes or stay. The margins of
// `vs`, the visual box without the strokes in symbols' art, scale; those strokes keep their
// weight when an instance scales, so the rest of the margins stay.
// weight when an instance scales, so the rest of the margins stay. Type measured by its glyphs
// is measured as drawn after each scale tried instead (below).
let (v, vs) = if s.prefs.use_preview_bounds {
(effects::preview::oriented_bounds(d, &ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g))
let v = effects::preview::oriented_bounds_with(d, &ids, how).map_or(g, |b| b.rect);
(v, if by_glyphs { v } else { scaling_box(d, &ids, &frame).unwrap_or(g) })
} else {
(g, g)
};
@@ -125,16 +132,20 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchor
// The scales and the point above can miss the size or the reference point when the margins of
// the objects differ, the members of groups and the objects in symbols' art included, and with
// strokes that scale, when the sizes are out of proportion. The objects are then measured one
// by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, and
// by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects,
// objects with a butt-capped stroke along a segment at a slant, whose margins change with the
// segment's direction, are measured as drawn after each scale tried instead ([`refit_drawn`]).
let symbols = d.symbol_boxes();
// segment's direction, and type measured by its glyphs, are measured as drawn after each scale
// tried instead ([`refit_drawn`]).
let to_local = frame.to_doc().inverse();
let drawn = || {
ids.iter()
.filter_map(|id| d.node(*id))
.any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local))
by_glyphs
|| ids
.iter()
.filter_map(|id| d.node(*id))
.any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local))
};
// Without Use Preview Bounds the box has no margins (`v`, `vs` and `g` are one box, glyph ink
// included), so the scales above are exact and nothing is refitted.
let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) {
None
} else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() {
@@ -145,7 +156,7 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchor
ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| {
let mut n = n.clone();
n.transform(xf, sc);
vectorcraft_geom::union_opt(acc, effects::preview::bounds_in(&symbols, &n, to_local))
vectorcraft_geom::union_opt(acc, effects::preview::bounds_in_with(&symbols, &n, to_local, how))
})
};
// The scales above keep the effects' reach as a margin that stays, and give 0 for a size
+1 -1
View File
@@ -287,7 +287,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Bounds",
[],
None,
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))",
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; with the typeGlyphBounds preference, each point type object selected as an object (not inside a selected group) is measured by its glyphs' ink (underline and strikethrough included), which the scale gives the size asked for; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))",
has_selection,
set_bounds
),
+1 -1
View File
@@ -12,7 +12,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
// Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre.
let st = s.doc()?;
if !bool_or(p, "copy", false)
&& let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds)
&& let Some(b) = effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure())
{
return Ok(b.center());
}
+3
View File
@@ -140,6 +140,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("disableAutoAddDelete", "General", "Options", "Disable Auto Add/Delete", bool),
p!("usePreciseCursors", "General", "Options", "Use Precise Cursors", bool),
p!("showToolTips", "General", "Options", "Show Tool Tips", bool),
p!("showExternalScriptWarning", "General", "Options", "Show External Script Warning", bool),
p!("antiAliasedArtwork", "General", "Options", "Anti-aliased Artwork", bool),
p!("selectSameTintPercent", "General", "Options", "Select Same Tint %", bool),
p!("showHomeScreen", "General", "Options", "Show The Home Screen When No Documents Are Open", bool),
@@ -435,6 +436,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[
// The Align panel menu's Align to Glyph Bounds: Align and Distribute measure type by its glyphs' ink.
p!("alignGlyphBoundsPoint", "Type", "Align to Glyph Bounds", "Point Text", bool),
p!("alignGlyphBoundsArea", "Type", "Align to Glyph Bounds", "Area Text", bool),
// The bounding box and the Transform panel measure point type by its glyphs' ink (also in the Transform panel menu).
p!("typeGlyphBounds", "Type", "Glyph Bounds", "Measure Type by Glyph Bounds", bool),
// Artboard Options › Global: the canvas darkens the area outside the artboards while the Artboard tool is active.
p!("fadeRegionOutsideArtboard", "General", "Artboard Options", "Fade Region Outside Artboard", bool),
p!("artboardUpdateWhileDragging", "General", "Artboard Options", "Update While Dragging", bool),
+32 -2
View File
@@ -766,11 +766,14 @@ pub fn skip_reason(s: &Session, st: &DocState) -> Option<&'static str> {
return Some("unchanged since its last recovery copy");
}
if s.prefs.recovery_off_for_complex && object_count(&st.doc) > COMPLEX_OBJECTS {
return Some("complex document (Data Recovery is off for complex documents)");
return Some(COMPLEX_SKIP);
}
None
}
/// Why a complex document with unsaved changes gets no copy.
const COMPLEX_SKIP: &str = "complex document (Data Recovery is off for complex documents)";
/// A file name part from a title: letters, digits, `-`, `_` and spaces (others as `-`).
fn safe_stem(title: &str) -> String {
let stem: String = fileio::file_stem(title).chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ') { c } else { '-' }).collect();
@@ -866,8 +869,35 @@ fn remove_copy(s: &Session, file: &str) {
}
}
/// Document `uid` was saved, reverted or closed: its recovery copy goes.
/// Closed document `st`'s recovery copy goes.
pub(crate) fn forget_closed(s: &Session, st: &crate::DocState) {
if let Some(copy) = &st.recovery {
remove_copy(s, &copy.file);
}
}
/// Writes a copy of every document that needs one now. An error when a document with unsaved
/// changes would be left without a current copy.
pub(crate) fn save_copies(s: &mut Session) -> Result<()> {
let store = need_store(s, "script.run")?;
let (jobs, _) = jobs(s, &store)?;
let uncopied = s.documents().iter().any(|st| skip_reason(s, st) == Some(COMPLEX_SKIP));
if uncopied {
return Err(EngineError::Other("a document with unsaved changes has no recovery copy".into()));
}
for j in jobs {
j.write(store.as_ref()).map_err(EngineError::Other)?;
j.finish(s);
}
Ok(())
}
/// Document `uid` was saved, reverted or closed: its recovery copy goes. While a script runs the
/// run's commit settles that.
pub fn forget(s: &mut Session, uid: u64) {
if s.script_in_run {
return;
}
let Some(copy) = s.document_mut(uid).and_then(|st| st.recovery.take()) else { return };
remove_copy(s, &copy.file);
}
+128 -10
View File
@@ -11,21 +11,139 @@ use vectorcraft_scriptcheck::{Limits, Loaded, Loader, Report};
/// The ids a reply can hold, for the windows that show it.
pub use vectorcraft_scriptcheck::{IncludeStatus, Need, OUTSIDE, PROBLEMS};
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, str_param};
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, str_param};
use crate::script;
/// The extensions of the scripts File › Open shows a check for.
pub const EXTS: &[&str] = &["jsx", "jsxinc", "jsxbin"];
/// The extensions of the scripts `script.run` takes as a file, in any case.
pub const RUN_EXTS: &[&str] = &["js", "jsx"];
/// Does `path` name a file `script.run` takes?
pub fn runs(path: &str) -> bool {
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| RUN_EXTS.iter().any(|x| e.eq_ignore_ascii_case(x)))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
query "script.check",
"Check Script",
[],
None,
"{path? | data?: the script's text | dataBase64?, name?: the file's name (default: the path's; with a path, only the reply's name), includes?: false (with a path: also check the files its #include lines name, inside the script's folder; not on the web)} check a script written for the reference app (.jsx, .jsxinc, .js, or a compiled .jsxbin) without running any of it → {name, kind: script|compiled|module|notText|tooLarge, needs: [language|dom|files|menuCommands|dialogs|hostOnly], lines, targets: [{value, app, instance?, version?, locale?, line, file?}], directives: [{name, value, line, form: \"#\"|\"//@\", file?}], includes: [{value, line, status: checked|missing|notFollowed|cycle|notRead|limit|unreadable, path?, file?}], features: [{id, count, lines}], dom: {members: [{path, count}], types: [{name, count}]}, menuCommands: {strings: [{value, count, lines}], dynamic}, outside: [{id: filesWritten|filesOpened|network|nativeCode|otherApps|environment|unseenCode|preferences, count, lines, modes?}], problems: [{code, line?, file?, detail?}], truncated} (file: the included file it is in; lines: at most 20 of the script's own, while counts include the included files; at most 8 MiB a file and 32 MiB in all, 64 included files 16 deep, 1,024 paths looked up for them, 1,000 entries a list, text echoed cut to 120 characters)",
always,
check
)]
vec![
cmd!(
query "script.run",
"Run Script",
[],
None,
"{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: {} (no prompts exist yet: only an empty object is taken), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report; vc.args is args; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport",
always,
run
),
cmd!(
query "script.stop",
"Stop Script",
[],
None,
"{} stop the running script, which puts every document back as it was before the run; asking again while it stops is harmless → {stopped: whether a script was running}",
always,
stop
),
cmd!(
query "script.check",
"Check Script",
[],
None,
"{path? | data?: the script's text | dataBase64?, name?: the file's name (default: the path's; with a path, only the reply's name), includes?: false (with a path: also check the files its #include lines name, inside the script's folder; not on the web)} check a script written for the reference app (.jsx, .jsxinc, .js, or a compiled .jsxbin) without running any of it → {name, kind: script|compiled|module|notText|tooLarge, needs: [language|dom|files|menuCommands|dialogs|hostOnly], lines, targets: [{value, app, instance?, version?, locale?, line, file?}], directives: [{name, value, line, form: \"#\"|\"//@\", file?}], includes: [{value, line, status: checked|missing|notFollowed|cycle|notRead|limit|unreadable, path?, file?}], features: [{id, count, lines}], dom: {members: [{path, count}], types: [{name, count}]}, menuCommands: {strings: [{value, count, lines}], dynamic}, outside: [{id: filesWritten|filesOpened|network|nativeCode|otherApps|environment|unseenCode|preferences, count, lines, modes?}], problems: [{code, line?, file?, detail?}], truncated} (file: the included file it is in; lines: at most 20 of the script's own, while counts include the included files; at most 8 MiB a file and 32 MiB in all, 64 included files 16 deep, 1,024 paths looked up for them, 1,000 entries a list, text echoed cut to 120 characters)",
always,
check
),
]
}
/// A refusal of `script.run`'s parameters, which only a program's own call can give.
fn param(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: "script.run".into(), msg: msg.into() }
}
/// `maxFuel`: a whole number from 1 to [`script::MAX_FUEL`], or the default when omitted.
fn max_fuel(p: &Value) -> Result<u64> {
let Some(v) = p.get("maxFuel") else { return Ok(script::DEFAULT_FUEL) };
v.as_u64()
.filter(|n| (1..=script::MAX_FUEL).contains(n))
.ok_or_else(|| param(format!("`maxFuel` must be a whole number from 1 to {}", script::MAX_FUEL)))
}
/// The text of a script read as bytes: a byte order mark is left out, and compiled scripts and
/// bytes that are not UTF-8 are refused.
fn script_text(bytes: &[u8]) -> Result<String> {
if bytes.starts_with(b"@JSXBIN@") {
return Err(EngineError::Other(COMPILED.to_string()));
}
let text = std::str::from_utf8(bytes).map_err(|_| EngineError::Other(NOT_TEXT.to_string()))?;
Ok(text.strip_prefix('\u{feff}').unwrap_or(text).to_string())
}
const COMPILED: &str = "this is a compiled script, which cannot be run; use its source text";
const NOT_TEXT: &str = "the script is not UTF-8 text";
/// The error for a script over [`script::MAX_SOURCE_BYTES`].
pub const TOO_LARGE: &str = "the script is too large to run";
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let optional_str = |key: &str| match p.get(key) {
None => Ok(None),
Some(Value::String(t)) => Ok(Some(t.as_str())),
Some(_) => Err(param(format!("`{key}` must be a string"))),
};
let (path, source, given_name) = (optional_str("path")?, optional_str("source")?, optional_str("name")?);
// Checked before anything copies or writes out `args`, which would go one call deeper per level.
if let Some(place) = p.get("args").and_then(script::too_deep) {
return Err(param(format!("`args{place}` is nested more than 64 levels deep")));
}
let args = match p.get("args") {
None => serde_json::Map::new(),
Some(Value::Object(o)) => o.clone(),
Some(_) => return Err(param("`args` must be an object")),
};
if serde_json::to_vec(&args).map_or(true, |b| b.len() > script::MAX_ARGS_BYTES) {
return Err(param("`args` is larger than 1 MiB"));
}
match p.get("answers") {
None => {}
Some(Value::Object(o)) if o.is_empty() => {}
Some(_) => return Err(param("`answers` must be an empty object: scripts cannot ask questions yet")),
}
let fuel = max_fuel(p)?;
let text = match (path, source) {
(Some(""), None) => return Err(param("`path` is empty")),
(Some(path), None) => {
if path.to_ascii_lowercase().ends_with(".jsxbin") {
return Err(EngineError::Other(COMPILED.to_string()));
}
if !runs(path) {
return Err(param("`path` must name a .js or .jsx file"));
}
// One byte more than a script may have shows that the file is too large.
let bytes = super::fileio::read_file_head(path, script::MAX_SOURCE_BYTES + 1)?;
if bytes.len() > script::MAX_SOURCE_BYTES {
return Err(EngineError::Other(TOO_LARGE.to_string()));
}
script_text(&bytes)?
}
(None, Some(source)) => {
if source.len() > script::MAX_SOURCE_BYTES {
return Err(EngineError::Other(TOO_LARGE.to_string()));
}
script_text(source.as_bytes())?
}
_ => return Err(param("give exactly one of `path` and `source`")),
};
let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string());
s.script_start(script::Request { name: name.clone(), source: text, args, fuel })?;
if s.script_frames {
return Ok(json!({"running": true, "name": name}));
}
s.script_run_to_end()
}
fn stop(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"stopped": s.script_request_stop()}))
}
/// The folder a script is in, which its included files are read from.
+4 -3
View File
@@ -140,7 +140,7 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result<Value> {
}
s.end_text_edit();
let st = s.doc()?;
let back = Return { redo: st.history.redo.clone(), before: Some(st.doc.clone()) };
let back = Return { redo: Arc::new(st.history.redo.clone()), before: Some(st.doc.clone()) };
let (isolation, active_layer) = (st.isolation, st.active_layer);
let layer = s.edit(ENTER, |d, sel| {
let layer = begin(d, sel, &name, xf, instance)?;
@@ -229,7 +229,7 @@ pub(crate) fn leave(s: &mut Session, _: &Value) -> Result<Value> {
}
st.selection = Arc::unwrap_or_clone(entry.selection);
st.selection.prune(&st.doc);
st.history.redo = redo;
st.history.redo = Arc::unwrap_or_clone(redo);
st.revision += 1;
} else {
s.edit("Exit Symbol Editing Mode", |d, sel| {
@@ -257,9 +257,10 @@ pub(crate) fn finish(d: &mut Document, sel: &mut Selection) {
/// The redo steps entering symbol editing mode cleared, which leaving without an edit gives back,
/// and the document entering started from, which tells the entering step apart from any other.
/// Copies of the document's state share the redo steps.
#[derive(Clone, Debug, Default)]
pub(crate) struct Return {
redo: Vec<crate::HistoryEntry>,
redo: Arc<Vec<crate::HistoryEntry>>,
before: Option<Arc<Document>>,
}
+28
View File
@@ -157,6 +157,7 @@ pub struct BuiltinSymbolLibrary {
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 },
];
/// Built-in library `id`, made once.
@@ -221,6 +222,33 @@ fn ui_frames() -> SymbolLibrary {
SymbolLibrary { name: "UI Frames".into(), doc }
}
/// Map Markers: pin, flag, crosshair and checkpoint for map/HUD art (original, no 9-slice).
fn map_markers() -> SymbolLibrary {
let c = Point::ZERO;
let pin = [(0.0, 22.0), (-9.0, 4.0), (-12.0, -6.0), (-8.0, -16.0), (0.0, -20.0), (8.0, -16.0), (12.0, -6.0), (9.0, 4.0)];
let flag = [(-16.0, 22.0), (-12.0, 22.0), (-12.0, -8.0), (18.0, -14.0), (-12.0, -20.0), (-12.0, -22.0), (-16.0, -22.0)];
let pin: Vec<Point> = pin.iter().map(|&(x, y)| Point::new(x, y)).collect();
let flag: Vec<Point> = flag.iter().map(|&(x, y)| Point::new(x, y)).collect();
let markers = [
("Pin", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pin, true)), "#e74c3c"),
("Flag", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&flag, true)), "#3498db"),
("Crosshair", shapes::star(c, 20.0, 5.0, 4, 0.0), "#2c3e50"),
("Checkpoint", shapes::polygon(c, 18.0, 4, 0.0), "#27ae60"),
];
let mut doc = Document::new(100.0, 100.0);
for (name, path, hex) in markers {
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 = "Map Markers".into();
SymbolLibrary { name: "Map Markers".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 })
+2
View File
@@ -1611,6 +1611,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
list_level_counts: Vec::new(),
cached_bounds: None,
cached_baselines: Vec::new(),
cached_ink: None,
};
super::create::new_type_alignment(s, p, C, &mut t)?;
if !on_path {
@@ -1901,6 +1902,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
list_level_counts: Vec::new(),
cached_bounds: None,
cached_baselines: Vec::new(),
cached_ink: None,
};
for r in &mut h.runs {
r.style.tracking = tr;
+2
View File
@@ -304,6 +304,7 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
t.cached_bounds = Some(lay.bounds);
t.cached_baselines = lay.baselines();
t.cached_ink = Some((lay.bounds, vectorcraft_render::effects::preview::laid_out_ink(&lay, t)));
}
/// [`refresh_bounds`] for a document that is opening: the first frame takes the layout
@@ -315,6 +316,7 @@ pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
t.cached_bounds = Some(lay.bounds);
t.cached_baselines = lay.baselines();
t.cached_ink = Some((lay.bounds, vectorcraft_render::effects::preview::laid_out_ink(&lay, t)));
vectorcraft_text::keep_layout(t, stamp, lay);
}
+1
View File
@@ -83,6 +83,7 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) {
/// Instances and Lock Guides change the selection, and the proof views, the preferences and added
/// fonts give the documents new revisions (they draw again, and type lays out again).
const WAIT_FOR_TOOL_EDIT: &[&str] = &[
"script.run",
"command.batch",
"document.find",
"symbol.selectInstances",
+1 -1
View File
@@ -446,7 +446,7 @@ impl DocState {
}
let e = HistoryEntry::kept(label, doc, selection, &self.doc);
self.history.undo.push(e);
self.history.redo.clear();
self.history.clear_redo();
self.history.trim();
}
/// End the interaction in progress, undoing what it changed.
+6
View File
@@ -1,5 +1,8 @@
use super::{NodeId, cancel};
/// The text of [`EngineError::Busy`], which ends every error a frontend reports for it.
pub const SCRIPT_BUSY: &str = "a script is running: stop it or wait for it to finish";
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
@@ -20,6 +23,9 @@ pub enum EngineError {
/// The caller asked for the command to stop ([`cancel`]). It changed nothing.
#[error("cancelled; nothing was changed")]
Cancelled,
/// A running script holds the documents until it ends. Nothing was changed.
#[error("{}", SCRIPT_BUSY)]
Busy,
/// A bug: the command panicked. The document was rolled back to its state before the command.
#[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")]
Internal { cmd: String, msg: String },
+34 -1
View File
@@ -208,7 +208,40 @@ pub fn check_read(path: &str) -> Result<(), String> {
/// May `path` be written here? Always, without roots in force.
pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write))
current().map_or(Ok(()), |r| r.check(path, Access::Write))?;
WRITES.with(|w| {
if let Some(paths) = w.borrow_mut().as_mut()
&& !paths.iter().any(|(p, _)| p == path)
{
paths.push((path.to_string(), stamp(path)));
}
});
Ok(())
}
/// A file's modification time and length; `None` when there is no file at `path`.
type Stamp = Option<(Option<std::time::SystemTime>, u64)>;
fn stamp(path: &str) -> Stamp {
std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file).map(|m| (m.modified().ok(), m.len()))
}
thread_local! {
/// The paths [`check_write`] allowed on this thread while they are being recorded, each with
/// its file's stamp at that moment.
static WRITES: std::cell::RefCell<Option<Vec<(String, Stamp)>>> = const { std::cell::RefCell::new(None) };
}
/// Starts recording the paths [`check_write`] allows on this thread.
pub(crate) fn start_recording_writes() {
WRITES.with(|w| *w.borrow_mut() = Some(Vec::new()));
}
/// Ends recording → the recorded paths whose file exists now: after a command that `succeeded`,
/// all of them; after one that failed, those made or changed since they were allowed.
pub(crate) fn finish_recording_writes(succeeded: bool) -> Vec<String> {
let recorded = WRITES.with(|w| w.borrow_mut().take()).unwrap_or_default();
recorded.into_iter().filter(|(path, before)| stamp(path).is_some_and(|now| succeeded || Some(now) != *before)).map(|(path, _)| path).collect()
}
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
+120 -2
View File
@@ -1,7 +1,13 @@
use super::{Arc, Document, Selection};
#[cfg(test)]
thread_local! {
/// Tests: how many undo steps were copied on this thread.
pub(crate) static ENTRY_CLONES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// One undo step.
#[derive(Clone, Debug)]
#[derive(Debug)]
pub struct HistoryEntry {
pub label: String,
/// The document as it was. It keeps no lookup cache ([`Document::drop_lookup_cache`]): a
@@ -27,6 +33,25 @@ pub struct HistoryEntry {
color_mode_bytes: usize,
}
impl Clone for HistoryEntry {
fn clone(&self) -> Self {
#[cfg(test)]
ENTRY_CLONES.with(|n| n.set(n.get() + 1));
HistoryEntry {
label: self.label.clone(),
doc: self.doc.clone(),
selection: self.selection.clone(),
bytes: self.bytes,
next_bytes: self.next_bytes,
next: self.next,
named: self.named.clone(),
serial: self.serial,
color_mode_paints: self.color_mode_paints.clone(),
color_mode_bytes: self.color_mode_bytes,
}
}
}
/// The next [`HistoryEntry::serial`]: 1 for the first step made, so 0 never names a step.
fn next_serial() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -148,11 +173,52 @@ pub struct History {
/// About the most memory the steps may keep ([`HistoryEntry::bytes`]): past it the oldest
/// steps go, but never the last [`History::MIN_STEPS`] undo (and redo) steps.
pub byte_budget: usize,
/// Counts the changes made in place to steps below the newest ([`History::put_back`]).
pub(crate) rewrites: u64,
/// Between [`History::mark`] and [`History::release`] or [`History::put_back`].
pub(crate) held: bool,
/// The redo steps a new step cleared while held ([`History::clear_redo`]).
pub(crate) set_aside: Option<Vec<HistoryEntry>>,
}
impl Default for History {
fn default() -> Self {
History { undo: vec![], redo: vec![], limit: 0, byte_budget: default_history_budget() }
History { undo: vec![], redo: vec![], limit: 0, byte_budget: default_history_budget(), rewrites: 0, held: false, set_aside: None }
}
}
/// What [`History::put_back`] needs to give a history back as it was when [`History::mark`] took
/// it: the newest undo and redo steps, and where each stack stood under them. It holds no other
/// step, so taking one costs the same however many the history holds.
pub(crate) struct HistoryMark {
undo: StackMark,
redo: StackMark,
limit: usize,
byte_budget: usize,
rewrites: u64,
}
/// One stack of a [`HistoryMark`].
struct StackMark {
len: usize,
/// [`HistoryEntry::serial`] of the step under the newest one, 0 for none.
below: u64,
newest: Option<HistoryEntry>,
}
impl StackMark {
fn of(steps: &[HistoryEntry]) -> Self {
let len = steps.len();
StackMark { len, below: len.checked_sub(2).and_then(|i| steps.get(i)).map_or(0, |e| e.serial), newest: steps.last().cloned() }
}
/// Puts `steps` back as they were. → `false` when a step under the newest one was removed since.
fn put_back(self, steps: &mut Vec<HistoryEntry>) -> bool {
let kept = self.len.saturating_sub(1);
let intact = steps.len() >= kept && kept.checked_sub(1).is_none_or(|i| steps.get(i).is_some_and(|e| e.serial == self.below));
steps.truncate(kept);
steps.extend(self.newest);
intact
}
}
@@ -208,6 +274,58 @@ impl History {
self.redo.drain(..n);
}
/// A [`HistoryMark`] of the history now. Until [`History::release`] or [`History::put_back`],
/// [`History::trim`] lets no step go, so the steps under the newest one stay where they are, and
/// [`History::clear_redo`] sets the redo steps aside.
pub(crate) fn mark(&mut self) -> HistoryMark {
let mark = HistoryMark {
undo: StackMark::of(&self.undo),
redo: StackMark::of(&self.redo),
limit: self.limit,
byte_budget: self.byte_budget,
rewrites: self.rewrites,
};
self.limit = usize::MAX;
self.byte_budget = usize::MAX;
self.held = true;
self.set_aside = None;
mark
}
/// Removes the redo steps, as a new step does. While held ([`History::mark`]) the first call
/// keeps them for [`History::put_back`].
pub(crate) fn clear_redo(&mut self) {
if self.held && self.set_aside.is_none() {
self.set_aside = Some(std::mem::take(&mut self.redo));
} else {
self.redo.clear();
}
}
/// Keeps the history as it is now: the limits `mark` held back apply again.
pub(crate) fn release(&mut self, mark: &HistoryMark) {
self.held = false;
self.set_aside = None;
self.limit = mark.limit;
self.byte_budget = mark.byte_budget;
self.trim();
}
/// Gives the history back as it was at `mark`. → `false` when that is not possible: an undo or
/// redo step under the newest one was removed or changed in place since.
pub(crate) fn put_back(&mut self, mark: HistoryMark) -> bool {
if let Some(redo) = self.set_aside.take() {
self.redo = redo;
}
let undo = mark.undo.put_back(&mut self.undo);
let redo = mark.redo.put_back(&mut self.redo);
let intact = self.rewrites == mark.rewrites && undo && redo;
self.held = false;
self.limit = mark.limit;
self.byte_budget = mark.byte_budget;
intact
}
/// Estimate the newest undo step again, its next state now being `now` (the steps after it were
/// merged into it, or an undo group extended it). It takes a new number ([`History::step`]).
pub(crate) fn recost_last(&mut self, now: &Document) {
+17 -1
View File
@@ -243,6 +243,21 @@ pub struct Session {
pub(crate) screens_hand_off: Option<Option<cmd::fileio::ScreenExport>>,
/// Where Data Recovery keeps its copies ([`cmd::recovery`]).
pub recovery: cmd::recovery::Recovery,
/// The running script's job, while there is one: the mutation lease ([`script`]).
pub(crate) script_key: Option<script::JobKey>,
/// Stops the running script at its next instruction; the same flag for every run.
pub(crate) script_stop: vectorcraft_js::Interrupt,
/// The report of the run that ended last.
pub(crate) script_report: Option<Value>,
/// `script.run` starts the job and returns, and the frontend advances it once per frame
/// ([`Session::script_pump`]). Off, `script.run` advances it to its end.
pub script_frames: bool,
/// Each open document's highest next id a run has seen, by [`DocState::uid`].
pub(crate) script_high_water: std::collections::HashMap<u64, u64>,
/// While a slice of a script runs in this session.
pub(crate) script_in_run: bool,
/// Write recovery copies of the unsaved documents before a run, and don't start without them.
pub script_copy_first: bool,
/// While the active tool's actions run: their commands aren't "a command from outside the
/// tool" ([`Session::after_command`]).
pub(crate) in_tool_actions: bool,
@@ -329,6 +344,7 @@ mod history;
mod interaction;
mod prefs;
mod sanity;
pub mod script;
mod session;
#[cfg(test)]
@@ -337,7 +353,7 @@ pub(crate) use command_sets::SELECTS_GIVEN_IDS;
use command_sets::given_ids;
pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit};
pub use doc_state::{DocState, OpenRows, RecalledSelection};
pub use error::{EngineError, Result};
pub use error::{EngineError, Result, SCRIPT_BUSY};
pub use history::{History, HistoryEntry, NamedStep, default_history_budget};
pub(crate) use interaction::PreviewCopies;
pub use interaction::{Interaction, LastTransform, UndoGroup, UndoScope};
+8 -5
View File
@@ -84,9 +84,9 @@ pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
impl Session {
/// Start stamping the active document's linked files on a worker thread, unless a look is
/// still running (or the build has no files to look at: the web app).
/// still running, a script is running (or the build has no files to look at: the web app).
pub fn start_link_scan(&mut self) {
if self.link_scan.is_some() || cfg!(target_arch = "wasm32") {
if self.link_scan.is_some() || self.script_key.is_some() || cfg!(target_arch = "wasm32") {
return;
}
let Some(st) = self.active() else { return };
@@ -115,10 +115,13 @@ impl Session {
}
}
/// Apply a finished look (`None` while there is none, or it is still running): see
/// [`Session::apply_link_stamps`]. A look whose worker ended without stamps is cleared, also
/// with `None`, so the next one can start.
/// Apply a finished look (`None` while there is none, it is still running, or a script is
/// running): see [`Session::apply_link_stamps`]. A look whose worker ended without stamps is
/// cleared, also with `None`, so the next one can start.
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
if self.script_key.is_some() {
return None;
}
self.link_scan.take_if(|scan| ended_without_stamps(scan, |ended| ended.load(Ordering::SeqCst)));
let (uid, stamps) = self.link_scan.as_ref()?.0.lock().unwrap_or_else(PoisonError::into_inner).take()?;
self.link_scan = None;
+10
View File
@@ -31,6 +31,8 @@ pub struct Prefs {
pub disable_auto_add_delete: bool,
pub use_precise_cursors: bool,
pub show_tool_tips: bool,
/// Other Script… warns before it runs a chosen script.
pub show_external_script_warning: bool,
pub anti_aliased_artwork: bool,
pub select_same_tint_percent: bool,
pub show_home_screen: bool,
@@ -41,6 +43,12 @@ pub struct Prefs {
/// Align panel menu › Align to Glyph Bounds › Area Text: the same for area type, instead of
/// its frame.
pub align_glyph_bounds_area: bool,
/// Preferences › Type › Glyph Bounds › Measure Type by Glyph Bounds (also in the Transform
/// panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X,
/// Y, W and H, and `object.setBounds` measure each point type object selected as an object (not
/// inside a selected group) by the ink of its glyphs instead of its em box
/// ([`crate::Session::bounds_measure`]).
pub type_glyph_bounds: bool,
/// Artboard Options › Fade Region Outside Artboard: while the Artboard tool is active, the
/// canvas draws everything outside the artboards darker, the pasteboard and the art on it.
pub fade_region_outside_artboard: bool,
@@ -366,12 +374,14 @@ impl Default for Prefs {
disable_auto_add_delete: false,
use_precise_cursors: false,
show_tool_tips: true,
show_external_script_warning: true,
anti_aliased_artwork: true,
select_same_tint_percent: false,
show_home_screen: true,
use_preview_bounds: false,
align_glyph_bounds_point: false,
align_glyph_bounds_area: false,
type_glyph_bounds: false,
fade_region_outside_artboard: true,
artboard_update_while_dragging: true,
display_print_size: false,
+7 -3
View File
@@ -26,10 +26,10 @@ use crate::cmd::{CommandSpec, bad, command_specs};
use crate::{EngineError, Result};
/// The API level of the commands' parameters.
pub const API_VERSION: u32 = 1;
pub const API_VERSION: u32 = 2;
/// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s.
pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c)];
pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653)];
/// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]).
pub fn schema_hash() -> u64 {
@@ -296,8 +296,12 @@ mod tests {
("view.capsLock", json!([])),
("view.saved.new", json!(5)),
("view.transparencyGrid", json!("on")),
("engine.apiReference", json!({})),
("engine.apiReference", json!(null)),
("engine.apiReference", json!({"prefix": "view."})),
("engine.apiReference", json!([])),
];
assert_eq!(schemas().len(), 6);
assert_eq!(schemas().len(), 7);
for (id, schema) in schemas() {
let written = schema.to_json();
check_schema(&written, id).unwrap_or_else(|e| panic!("{e}\n{written:#}"));
File diff suppressed because it is too large Load Diff
+7
View File
@@ -129,6 +129,13 @@ impl Session {
link_scan: None,
global_edit_options: Default::default(),
keyboard_text: false,
script_key: None,
script_stop: vectorcraft_js::Interrupt::new(),
script_report: None,
script_frames: false,
script_high_water: Default::default(),
script_in_run: false,
script_copy_first: cfg!(target_arch = "wasm32"),
}
}
+9
View File
@@ -7,6 +7,9 @@ impl Session {
/// Run `f` on a mutable copy of the active document. Outside an interaction this records one
/// undo step labelled `label`; inside one it just mutates (the interaction commits later).
pub fn edit<T>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result<T>) -> Result<T> {
if self.script_running() {
return Err(EngineError::Busy);
}
// An edit made while typing (the Transform panel) is a step of its own: inside the typing
// session, the next keystroke's preview would start again from before it (#1881).
if !self.previewing && cmd::typing_in_progress(self) {
@@ -225,6 +228,9 @@ impl Session {
}
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
if self.script_running() {
return Err(EngineError::Busy);
}
// Typing in progress is kept first: a drag or a dialog's preview would otherwise start
// from the snapshot taken before the typing, and drop it (#1881).
if label != "Typing" && cmd::typing_in_progress(self) {
@@ -262,6 +268,9 @@ impl Session {
/// Re-apply `cmd` on top of the interaction snapshot (replacing the previous preview).
pub fn preview(&mut self, cmd: &str, params: &Value) -> Result<Value> {
if self.script_running() {
return Err(EngineError::Busy);
}
let (paint, restore_paint) = {
let menu = self.menu.clone();
let st = self.doc_mut()?;
+18 -3
View File
@@ -39,6 +39,14 @@ impl Session {
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
// A clipboard import is a query that replaces the clipboard, which the end of a run sets too.
if self.depth == 0 && self.script_key.is_some() && (waits_for_tool_edit(id) || id.starts_with("clipboard.import")) {
return Err(EngineError::Busy);
}
// A step of a batch a script runs is held to the same rule as the script's own commands.
if self.script_in_run && crate::script::refused_to_scripts(id) {
return Err(EngineError::Other(crate::script::refused_text(id)));
}
// An edit the active tool is computing on another thread (a Shape Builder merge) is
// applied before a command that may edit, select, save or give a document a new revision,
// as the click came first. Queries that only read do not wait for it.
@@ -302,8 +310,13 @@ impl Session {
/// document goes back to how it was before the command (its art, selection, interaction and
/// undo history), and the same document stays active, instead of crashing the frontend.
pub(crate) fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result<Value>) -> Result<Value> {
let snapshot: Vec<_> =
self.docs.iter().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone(), d.history.clone())).collect();
// In a script's run a panic ends the run, and the run's rollback puts the histories back.
let in_run = self.script_in_run;
let snapshot: Vec<_> = self
.docs
.iter()
.map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone(), (!in_run).then(|| d.history.clone())))
.collect();
let active = self.active().map(|d| d.uid);
let r = match guard::catch_panic(|| self.run_nested(f)) {
Ok(r) => r,
@@ -316,7 +329,9 @@ impl Session {
st.doc = doc;
st.selection = selection;
st.interaction = interaction;
st.history = history;
if let Some(history) = history {
st.history = history;
}
cmd::globaledit::follow_history(st);
st.drop_stale_variables_highlight();
st.revision += 1;
+2
View File
@@ -149,6 +149,7 @@ mod repeat_edit;
mod save;
mod saveoptions;
mod scalestrokes;
mod script_run;
mod scripts;
mod select_ids_first;
mod shaper;
@@ -186,6 +187,7 @@ mod transformfx;
mod transparencygrid;
mod trap;
mod typearea;
mod typeglyphbounds;
mod typescale;
mod typeundo;
mod units;
+20
View File
@@ -147,6 +147,26 @@ fn a_look_that_stores_its_stamps_between_the_polls_reads_keeps_them() {
assert!(s.link_scan.is_none());
}
#[test]
fn a_change_during_a_script_waits_for_the_script_to_end() {
let dir = Folder::new("watch-script");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = "automatically".into();
let id = place(&mut s, &path);
look(&mut s);
save(&path, &png(40, 40, BLUE));
s.start_link_scan();
s.wait_link_scan();
s.script_frames = true;
s.execute("script.run", &json!({"source": "while (true) {}"})).unwrap();
assert!(s.poll_link_scan().is_none(), "the finished look waits");
assert!(s.script_abandon());
assert_eq!(s.poll_link_scan().expect("the stamps are applied").unwrap()["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
}
#[test]
fn a_change_during_a_drag_waits_for_the_drag_to_end() {
let dir = Folder::new("watch-drag");
+999
View File
@@ -0,0 +1,999 @@
//! `script.run` and `script.stop`: one transaction per run, the rollback after a throw, a limit, a
//! stop or a panic, the mutation lease between slices, ids, growth and what a run reports.
use std::cell::Cell;
use serde_json::{Value, json};
use crate::script::{PANIC_IN_SLICE, TEST_GROWTH, TEST_RESULT_BYTES};
use crate::*;
fn rect(x: i64) -> Value {
json!({"x": x, "y": 0, "width": 10, "height": 10})
}
/// Two open documents, each with one rectangle, the second one active.
fn two_docs() -> Session {
let mut s = Session::new();
for x in [0, 50] {
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &rect(x)).unwrap();
}
s
}
/// What a rollback has to put back.
fn state(s: &Session) -> Value {
let docs: Vec<Value> = s
.documents()
.iter()
.map(|d| {
json!({
"uid": d.uid,
"art": format!("{:?}", d.doc.layers),
"selection": d.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
"undo": d.history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>(),
"redo": d.history.redo.len(),
"path": d.path,
"dirty": d.is_dirty(),
})
})
.collect();
json!({"docs": docs, "active": s.active().map(|d| d.uid), "journal": s.journal.len(), "paint": format!("{:?}", s.paint)})
}
fn run(s: &mut Session, source: &str) -> Result<Value> {
s.execute("script.run", &json!({"source": source, "name": "t.js"}))
}
fn objects(s: &Session, doc: usize) -> usize {
s.documents()[doc].doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum()
}
/// A framed run of `source`, one slice in.
fn paused(s: &mut Session, source: &str) {
s.script_frames = true;
assert_eq!(run(s, source).unwrap()["running"], true);
assert!(s.script_pump().is_none());
assert!(s.script_running());
}
fn two_rect_script() -> String {
"vc.run('shape.rectangle', {x:100,y:0,width:5,height:5});\
vc.run('shape.rectangle', {x:110,y:0,width:5,height:5});\
vc.run('document.activate', {index:0});\
vc.run('shape.rectangle', {x:120,y:0,width:5,height:5});\
vc.run('document.activate', {index:1});"
.to_string()
}
#[test]
fn a_run_is_one_undo_step_in_each_document_it_changed() {
let mut s = two_docs();
let before = state(&s);
let r = run(&mut s, &two_rect_script()).unwrap();
assert_eq!(r["ok"], true);
assert_eq!((objects(&s, 0), objects(&s, 1)), (2, 3));
assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script");
assert_eq!(s.documents()[1].history.undo.last().unwrap().label, "Run Script");
assert_eq!(s.documents()[1].history.undo.len(), before["docs"][1]["undo"].as_array().unwrap().len() + 1);
let after = format!("{:?}", s.documents()[1].doc.layers);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(objects(&s, 1), 1);
assert_eq!(format!("{:?}", s.documents()[1].doc.layers), before["docs"][1]["art"].as_str().unwrap());
assert_eq!(s.documents()[1].selection.objects.len(), before["docs"][1]["selection"].as_array().unwrap().len());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(format!("{:?}", s.documents()[1].doc.layers), after);
}
#[test]
fn a_run_that_changes_nothing_leaves_no_step_no_mark_and_no_journal_entry() {
for source in ["", "var a = 1 + 2;", "vc.log(vc.document); vc.commands.length;"] {
let mut s = two_docs();
s.execute("document.save", &json!({"path": std::env::temp_dir().join(format!("vc-noop-{}.svg", std::process::id())).to_string_lossy()}))
.unwrap();
let before = state(&s);
let revisions = |s: &Session| s.documents().iter().map(|d| d.revision).collect::<Vec<_>>();
let revisions_before = revisions(&s);
let r = run(&mut s, source).unwrap();
assert_eq!(r["changed"], json!([]), "{source}");
assert_eq!(state(&s), before, "{source}");
assert_eq!(revisions(&s), revisions_before, "{source}");
}
}
#[test]
fn a_caught_command_failure_leaves_nothing_behind_and_the_script_goes_on() {
let mut s = two_docs();
let source = "var m = '';\
try { vc.run('command.batch', {commands: [\
{command:'shape.rectangle', params:{x:1,y:1,width:1,height:1}},\
{command:'no.such', params:{}}]}); } catch (e) { m = e.message; }\
try { vc.run('shape.rectangle', {x:'bad'}); } catch (e) { m += '|' + e.name; }\
vc.log(m); vc.run('shape.rectangle', {x:200,y:0,width:5,height:5});";
let r = run(&mut s, source).unwrap();
assert_eq!(objects(&s, 1), 2, "{r}");
assert!(r["log"][0].as_str().unwrap().contains("|Error"), "{r}");
}
#[test]
fn a_failure_puts_back_every_document_and_the_session() {
enum How {
Throw,
Fuel,
Stop,
Panic,
}
for how in [How::Throw, How::Fuel, How::Stop, How::Panic] {
let mut s = two_docs();
let before = state(&s);
let edits = two_rect_script();
let r = match how {
How::Throw => run(&mut s, &format!("{edits} throw new Error('boom');")),
How::Fuel => s.execute("script.run", &json!({"source": format!("{edits} while (true) {{}}"), "maxFuel": 2_000_000})),
How::Stop => {
paused(&mut s, &format!("{edits} while (true) {{}}"));
assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true);
assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true);
s.script_pump().unwrap()
}
How::Panic => {
paused(&mut s, &format!("{edits} while (true) {{}}"));
PANIC_IN_SLICE.with(|p| p.set(true));
let r = s.script_pump().unwrap();
PANIC_IN_SLICE.with(|p| p.set(false));
r
}
};
assert!(r.is_err());
assert_eq!(state(&s), before);
assert!(!s.script_running());
s.script_frames = false;
assert_eq!(run(&mut s, "1").unwrap()["ok"], true, "a later run starts");
}
}
#[test]
fn a_stop_with_no_run_reports_it() {
assert_eq!(Session::new().execute("script.stop", &json!({})).unwrap()["stopped"], false);
}
#[test]
fn an_error_names_the_script_its_place_and_kind() {
let mut s = two_docs();
let e = run(&mut s, "var a;\n throw new TypeError('no');").unwrap_err().to_string();
assert_eq!(e, "t.js: line 2, column 3: TypeError: no (thrown)");
let e = run(&mut s, "var = 1;").unwrap_err().to_string();
assert!(e.starts_with("t.js: line 1,") && e.ends_with("(syntax)"), "{e}");
assert!(run(&mut s, "app.activeDocument;").unwrap_err().to_string().contains("`vc`"));
assert!(run(&mut s, "#target x\n1").unwrap_err().to_string().contains("#target"));
assert_eq!(s.script_report().unwrap()["line"], 1);
}
#[test]
fn bad_parameters_are_refused_before_anything_runs() {
let mut s = two_docs();
let before = state(&s);
let mut deep = json!(1);
for _ in 0..100 {
deep = json!([deep]);
}
for p in [
json!({}),
json!({"source": "1", "args": {"a": deep}}),
json!({"path": "a", "source": "b"}),
json!({"path": ""}),
json!({"path": 1}),
json!({"source": "1", "nope": 1}),
json!({"source": "1", "args": []}),
json!({"source": "1", "args": {"a": "x".repeat(1 << 20)}}),
json!({"source": "1", "answers": {"a": 1}}),
json!({"source": "1", "answers": 1}),
json!({"source": "1", "maxFuel": 0}),
json!({"source": "1", "maxFuel": -1}),
json!({"source": "1", "maxFuel": 1.5}),
json!({"source": "1", "maxFuel": "9"}),
json!({"source": "1", "maxFuel": 100_000_000_001u64}),
] {
assert!(matches!(s.execute("script.run", &p), Err(EngineError::BadParams { .. })), "{p}");
}
assert_eq!(state(&s), before);
}
#[test]
fn files_are_checked_before_they_are_read() {
let dir = std::env::temp_dir().join(format!("vc-script-run-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let write = |name: &str, bytes: &[u8]| {
let p = dir.join(name);
std::fs::write(&p, bytes).unwrap();
p.to_string_lossy().into_owned()
};
let mut s = two_docs();
let path = |p: &str| json!({"path": p});
let ok = write("Bézier (1) & co.js", b"\xef\xbb\xbfvc.log(1);\r\nvc.log(2);\r\n");
let r = s.execute("script.run", &path(&ok)).unwrap();
assert_eq!((r["log"].clone(), r["name"].as_str()), (json!([1, 2]), Some("Bézier (1) & co.js")));
let invalid = write("a.js", b"var a = '\xff';");
assert!(s.execute("script.run", &path(&invalid)).unwrap_err().to_string().contains("UTF-8"));
let huge = write("huge.js", &vec![b' '; script::MAX_SOURCE_BYTES + 1]);
assert!(s.execute("script.run", &path(&huge)).unwrap_err().to_string().contains("too large"));
let compiled = write("b.jsxbin", b"@JSXBIN@ES@2.0@");
assert!(s.execute("script.run", &path(&compiled)).unwrap_err().to_string().contains("compiled"));
let renamed = write("c.js", b"@JSXBIN@ES@2.0@");
assert!(s.execute("script.run", &path(&renamed)).unwrap_err().to_string().contains("compiled"));
assert!(s.execute("script.run", &path(&dir.to_string_lossy())).is_err());
let huge_source = " ".repeat(script::MAX_SOURCE_BYTES + 1);
assert!(run(&mut s, &huge_source).unwrap_err().to_string().contains("too large"));
let other = write("notes.txt", b"vc.log(1);");
assert!(matches!(s.execute("script.run", &path(&other)), Err(EngineError::BadParams { .. })), "a text file that is not a script");
let upper = write("UP.JSX", b"vc.log(3);");
assert_eq!(s.execute("script.run", &path(&upper)).unwrap()["log"], json!([3]));
std::fs::create_dir_all(dir.join("folder.js")).unwrap();
assert!(s.execute("script.run", &path(&dir.join("folder.js").to_string_lossy())).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_batch_puts_back_the_session_and_names_a_file_it_wrote() {
let path = std::env::temp_dir().join(format!("vc-script-batch-{}.svg", std::process::id()));
let _ = std::fs::remove_file(&path);
let p = path.to_string_lossy().into_owned();
let mut s = two_docs();
let source = format!(
"try {{ vc.run('command.batch', {{commands: [\
{{command: 'view.guides.lock', params: {{locked: true}}}},\
{{command: 'document.export', params: {{path: {p:?}, format: 'svg'}}}},\
{{command: 'no.such', params: {{}}}}]}}); }} catch (e) {{ vc.log(e.name); }}"
);
let r = run(&mut s, &source).unwrap();
assert_eq!(r["log"], json!(["Error"]), "{r}");
assert!(!s.guides_locked(), "the failed batch's Lock Guides does not stay");
assert!(path.exists());
assert_eq!(r["written"], json!([p]), "{r}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn an_export_that_fails_names_no_file() {
let path = std::env::temp_dir().join(format!("vc-script-no-folder-{}", std::process::id())).join("out.svg");
let p = path.to_string_lossy().into_owned();
let mut s = two_docs();
let source = format!("try {{ vc.run('document.export', {{path: {p:?}, format: 'svg'}}); }} catch (e) {{ vc.log(e.name); }}");
let r = run(&mut s, &source).unwrap();
assert_eq!(r["log"], json!(["Error"]), "{r}");
assert!(!path.exists());
assert_eq!(r["written"], json!([]), "{r}");
}
#[test]
fn a_failed_run_puts_back_the_choices_its_commands_made() {
let mut s = two_docs();
let before = s.global_edit_options.clone();
let source = "vc.run('select.globalEditOptions', {appearance: !vc.run('select.globalEditOptions', {}).appearance}); throw new Error('x');";
assert!(run(&mut s, source).is_err());
assert_eq!(s.global_edit_options, before);
let source = "try { vc.run('command.batch', {commands: [\
{command: 'select.globalEditOptions', params: {size: true}},\
{command: 'no.such', params: {}}]}); } catch (e) {}";
run(&mut s, source).unwrap();
assert_eq!(s.global_edit_options, before, "the failed command's choice went back");
assert!(!s.caps_lock);
assert!(run(&mut s, "vc.run('view.capsLock', {on: true}); throw new Error('x');").is_err());
assert!(!s.caps_lock, "the Caps Lock state went back");
run(&mut s, "vc.run('view.capsLock', {on: true});").unwrap();
assert!(s.caps_lock, "a finished run keeps it");
}
#[test]
fn a_failed_run_puts_back_the_preferences_its_commands_changed() {
let mut s = two_docs();
let basic = s.prefs.new_art_basic;
assert!(run(&mut s, &format!("vc.run('appearance.setNewArtBasic', {{on: {}}}); throw new Error('x');", !basic)).is_err());
assert_eq!(s.prefs.new_art_basic, basic);
}
#[test]
fn a_finished_run_keeps_its_preferences_and_the_ones_the_app_changed_meanwhile() {
let mut s = two_docs();
let (basic, increment) = (s.prefs.new_art_basic, s.prefs.keyboard_increment);
paused(&mut s, &format!("vc.run('appearance.setNewArtBasic', {{on: {}}}); for (var i = 0; i < 2000000; i++) {{}}", !basic));
assert_eq!(s.prefs.new_art_basic, basic, "between slices the app sees the preferences before the run");
s.prefs.keyboard_increment = increment + 1.0;
s.execute("view.capsLock", &json!({"on": true})).unwrap();
while s.script_pump().is_none() {}
assert_eq!((s.prefs.new_art_basic, s.prefs.keyboard_increment), (!basic, increment + 1.0));
assert!(s.caps_lock, "the Caps Lock state the app reported during the run stays");
}
#[test]
fn a_script_cannot_load_the_place_cursor() {
let mut s = two_docs();
let e = run(&mut s, "vc.run('file.place.queue', {paths: []});").unwrap_err().to_string();
assert!(e.contains("a script cannot run `file.place.queue`"), "{e}");
let listed = run(&mut s, "vc.log(vc.commands.some(function (c) { return c.id === 'file.place.queue'; }));").unwrap();
assert_eq!(listed["log"], json!([false]), "{listed}");
}
#[test]
fn a_command_whose_result_cannot_cross_is_put_back() {
TEST_RESULT_BYTES.with(|b| b.set(Some(1)));
let mut s = two_docs();
let r = run(&mut s, "try { vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}); } catch (e) { vc.log(e.name); }");
TEST_RESULT_BYTES.with(|b| b.set(None));
let r = r.unwrap();
assert_eq!(r["log"], json!(["RangeError"]), "{r}");
assert_eq!(r["changed"], json!([]), "{r}");
assert_eq!((objects(&s, 0), objects(&s, 1)), (1, 1));
}
#[test]
fn an_abandoned_run_reports_the_files_it_wrote() {
let path = std::env::temp_dir().join(format!("vc-script-abandon-{}.svg", std::process::id()));
let _ = std::fs::remove_file(&path);
let p = path.to_string_lossy().into_owned();
let mut s = two_docs();
paused(&mut s, &format!("vc.run('document.export', {{path: {p:?}, format: 'svg'}}); while (true) {{}}"));
assert!(path.exists(), "the first slice exported");
assert!(s.script_abandon());
let report = s.script_report().unwrap();
assert_eq!((report["kind"].as_str(), report["written"].clone()), (Some("stopped"), json!([p])), "{report}");
assert!(s.script_failure_text().unwrap().contains(&p));
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_run_that_only_closes_a_document_keeps_its_journal_entry() {
let mut s = two_docs();
let journal = s.journal.len();
let r = run(&mut s, "vc.run('file.close', {index: 1});").unwrap();
assert_eq!(r["changed"], json!([]), "{r}");
assert_eq!(s.documents().len(), 1);
assert_eq!(s.journal.len(), journal + 1);
assert_eq!(s.journal.iter().last().unwrap().0, "file.close");
}
#[test]
fn a_command_that_can_fail_after_copying_has_the_clipboard_put_back() {
let mut s = two_docs();
let keep_clipboard = |s: &mut Session, id: &str| {
let save = script::Savepoint::take(s, id);
s.clipboard.source_artboard = Some(Default::default());
save.restore(s);
};
keep_clipboard(&mut s, "edit.cut");
assert_eq!(s.clipboard.source_artboard, None, "put back");
s.clipboard.source_artboard = Some(Default::default());
keep_clipboard(&mut s, "shape.rectangle");
assert!(s.clipboard.source_artboard.is_some(), "only those commands pay for a copy of the clipboard");
}
#[test]
fn a_commands_savepoint_copies_the_same_number_of_undo_steps_however_long_the_runs_history() {
let copied = |n: usize| {
let mut s = two_docs();
crate::history::ENTRY_CLONES.with(|c| c.set(0));
let r = run(&mut s, &format!("for (var i = 0; i < {n}; i++) vc.run('shape.rectangle', {{x:i,y:1,width:1,height:1}});")).unwrap();
assert_eq!(r["ok"], true);
assert_eq!(objects(&s, 1), n + 1);
crate::history::ENTRY_CLONES.with(Cell::get)
};
let (few, many) = (copied(20), copied(200));
// Each command copies at most its document's newest step, whatever the history holds.
assert!(many <= 200 + few, "{few} steps copied for 20 commands, {many} for 200");
}
#[test]
fn undo_redo_and_a_long_redo_stack_copy_the_same_number_of_steps_per_command() {
let copied = |n: usize| {
let mut s = two_docs();
crate::history::ENTRY_CLONES.with(|c| c.set(0));
let source = format!(
"for (var i = 0; i < {n}; i++) vc.run('shape.rectangle', {{x:i,y:1,width:1,height:1}});\
for (var i = 0; i < {n}; i++) vc.run('edit.undo');\
for (var i = 0; i < {n}; i++) vc.run('select.none');\
for (var i = 0; i < {n}; i++) vc.run('edit.redo');"
);
let r = run(&mut s, &source).unwrap();
assert_eq!(r["ok"], true, "{r}");
assert_eq!(objects(&s, 1), n + 1);
crate::history::ENTRY_CLONES.with(Cell::get)
};
let (few, many) = (copied(20), copied(200));
// Ten times the commands copy about ten times the steps: each command copies its document's
// newest undo and redo steps, whatever the stacks hold.
assert!(many <= 12 * few, "{few} steps copied for 80 commands, {many} for 800");
}
#[test]
fn a_failed_command_gives_back_the_redo_steps_and_an_undo_it_made() {
let mut s = two_docs();
for x in 1..=3 {
s.execute("shape.rectangle", &rect(x)).unwrap();
}
for _ in 0..2 {
s.execute("edit.undo", &json!({})).unwrap();
}
let before = state(&s);
let save = script::Savepoint::take(&mut s, "shape.rectangle");
s.execute("shape.rectangle", &rect(9)).unwrap();
assert_eq!(s.documents()[1].history.redo.len(), 0);
assert!(save.restore(&mut s), "the redo steps the new step cleared are put back");
assert_eq!(state(&s), before);
let save = script::Savepoint::take(&mut s, "edit.undo");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
assert!(save.restore(&mut s));
assert_eq!(state(&s), before);
for _ in 0..2 {
s.execute("edit.redo", &json!({})).unwrap();
}
assert_eq!(objects(&s, 1), 4);
}
#[test]
fn a_failed_command_at_the_history_limit_keeps_the_oldest_step() {
let mut s = two_docs();
s.docs[1].history.limit = 5;
let before = state(&s);
for x in 1..=5 {
s.execute("shape.rectangle", &rect(x)).unwrap();
}
let save = script::Savepoint::take(&mut s, "shape.rectangle");
s.execute("shape.rectangle", &rect(9)).unwrap();
assert!(save.restore(&mut s), "the step the limit would have let go is put back");
assert_eq!(objects(&s, 1), 6);
for _ in 0..5 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(state(&s)["docs"][1]["art"], before["docs"][1]["art"]);
}
#[test]
fn a_snapshot_too_large_to_cross_throws_instead_of_reading_as_no_document() {
let mut s = two_docs();
s.docs[1].path = Some("x".repeat(17 << 20));
let r = run(&mut s, "var m = ''; try { vc.document; m = 'read'; } catch (e) { m = e.name + ': ' + e.message; } vc.log(m);").unwrap();
let m = r["log"][0].as_str().unwrap();
assert!(m.starts_with("RangeError: vc.document.") && m.ends_with("is too large for a script"), "{m}");
}
#[test]
fn recovery_copies_made_between_slices_hold_the_art_before_the_run() {
let store = std::sync::Arc::new(cmd::recovery::MemoryStore::default());
let mut s = two_docs();
s.recovery.set_store(store.clone());
paused(&mut s, &format!("{} while (true) {{}}", two_rect_script()));
cmd::recovery::save_copies(&mut s).unwrap();
s.script_abandon();
drop(s);
let mut s = Session::new();
s.recovery.set_store(store);
let r = s.execute("file.recovery.restore", &json!({})).unwrap();
assert_eq!(r["restored"].as_array().map(Vec::len), Some(2), "{r}");
assert_eq!((objects(&s, 0), objects(&s, 1)), (1, 1), "the copies hold the art from before the run");
}
#[test]
fn values_that_cannot_cross_are_named_and_shared_ones_can() {
let mut s = two_docs();
let cases = [
("var a = {}; a.a = a; vc.run('shape.rectangle', a);", "RangeError|TypeError"),
("var a = {}, c = a; for (var i = 0; i < 100; i++) { c.n = {}; c = c.n; } vc.run('shape.rectangle', a);", "RangeError|TypeError"),
("vc.run('shape.rectangle', {x: 1/0});", "RangeError|TypeError"),
("vc.run('shape.rectangle', {x: function () {}});", "RangeError|TypeError"),
("vc.run('shape.rectangle', 7);", "TypeError"),
("vc.run(7);", "TypeError"),
];
for (source, kinds) in cases {
let e = run(&mut s, source).unwrap_err().to_string();
assert!(kinds.split('|').any(|k| e.contains(k)), "{source}: {e}");
}
let r = run(&mut s, "var p = {w: 5}; vc.log({a: p, b: p}); var l = ''; for (var i = 0; i < 20; i++) l += 'xxxxxxxxxx'; vc.log(l);").unwrap();
assert_eq!(r["log"][0], json!({"a": {"w": 5}, "b": {"w": 5}}));
assert_eq!(r["log"][1].as_str().unwrap().len(), 200);
}
#[test]
fn commands_outside_the_documents_are_refused_in_a_batch_too() {
let mut s = two_docs();
for source in [
"vc.run('prefs.set', {key: 'a', value: 1});",
"vc.run('script.run', {source: '1'});",
"vc.run('script.stop');",
"vc.run('edit.undoGroup.begin', {});",
"vc.run('view.proofColors', {on: true});",
"vc.run('view.proofSetup', {});",
"vc.run('view.overprintPreview', {on: true});",
"vc.run('view.separationsPreview', {});",
"vc.run('command.batch', {commands: [{command: 'prefs.reset', params: {}}]});",
] {
let e = run(&mut s, source).unwrap_err().to_string();
assert!(e.contains("cannot run"), "{source}: {e}");
}
let r = run(&mut s, "var c = vc.commands; var n = 0; for (var i = 0; i < c.length; i++) if (c[i].id.indexOf('prefs.') === 0) n++; vc.log(n); vc.log(c.length > 100);").unwrap();
assert_eq!(r["log"], json!([0, true]));
}
#[test]
fn a_script_cannot_clear_the_undo_steps_from_before_its_run() {
let mut s = two_docs();
let steps = s.documents()[1].history.undo.len();
let source = "vc.run('shape.rectangle', {x:9,y:9,width:1,height:1});\
var m = ''; try { vc.run('edit.clearHistory'); } catch (e) { m = e.message; } vc.log(m);";
let r = run(&mut s, source).unwrap();
assert_eq!(r["log"], json!(["a script cannot run `edit.clearHistory`"]), "{r}");
assert_eq!(s.documents()[1].history.undo.len(), steps + 1);
}
#[test]
fn the_vc_object_reads_the_active_document_and_cannot_be_changed() {
let mut s = two_docs();
let r = s
.execute(
"script.run",
&json!({"name": "x", "args": {"n": 2}, "source": "vc.args.n = 9; vc.api.version = 'x'; vc.log(vc.args.n); vc.log(vc.api.version); vc.log(vc.api.has('run')); vc.log(vc.api.has('nope')); vc.log(vc.document.artboards); vc.log(typeof vc.document.path);"}),
)
.unwrap();
assert_eq!(r["log"], json!([2, "1.0", true, false, 1, "object"]));
let mut empty = Session::new();
assert_eq!(run(&mut empty, "vc.log(vc.document === null);").unwrap()["log"], json!([true]));
assert!(run(&mut empty, "vc.run('shape.rectangle', {x:0,y:0,width:1,height:1});").unwrap_err().to_string().contains("Error"));
}
#[test]
fn the_log_is_bounded_and_a_script_can_catch_that() {
let mut s = two_docs();
let r = run(
&mut s,
"var n = 0; try { while (true) { vc.log(n); n++; } } catch (e) { vc.run('document.inspect', {}); throw new Error(e.name + ' at ' + n); }",
);
let e = r.unwrap_err().to_string();
assert!(e.contains(&format!("RangeError at {}", script::MAX_LOG_LINES)), "{e}");
}
#[test]
fn between_slices_every_change_is_refused_and_readers_see_the_committed_art() {
let mut s = two_docs();
let before = state(&s);
paused(&mut s, &format!("{} while (true) {{}}", two_rect_script()));
assert_eq!(state(&s), before);
for (id, p) in [
("shape.rectangle", rect(1)),
("clipboard.importSvg", json!({})),
("clipboard.importImage", json!({})),
("clipboard.importText", json!({})),
("clipboard.importPdf", json!({})),
("clipboard.importEmf", json!({})),
("clipboard.importEps", json!({})),
("edit.undo", json!({})),
("file.new", json!({})),
("file.close", json!({})),
("file.revert", json!({})),
("file.recovery.restore", json!({})),
("action.batch", json!({})),
("command.batch", json!({"commands": []})),
("effect.apply", json!({})),
("script.run", json!({"source": "1"})),
] {
assert!(matches!(s.execute(id, &p), Err(EngineError::Busy)), "{id}");
}
assert!(matches!(s.begin_interaction("x"), Err(EngineError::Busy)));
assert!(matches!(s.edit("x", |_, _| Ok(())), Err(EngineError::Busy)));
assert!(matches!(s.preview("shape.rectangle", &rect(1)), Err(EngineError::Busy)));
let press = tools::PointerEvent {
kind: tools::PointerKind::Down,
..serde_json::from_value(json!({"kind": "move", "pos": {"x": 0.0, "y": 0.0}})).unwrap()
};
assert!(matches!(s.pointer(&press, ViewInfo::default()), Err(EngineError::Busy)));
let inspected = s.execute("document.inspect", &json!({})).unwrap();
assert_eq!(inspected["layers"][0]["children"].as_array().map(Vec::len), Some(1), "{inspected}");
assert_eq!(state(&s), before, "the refused commands changed nothing");
s.script_abandon();
assert_eq!(state(&s), before);
assert!(s.execute("shape.rectangle", &rect(1)).unwrap()["id"].is_u64());
}
#[test]
fn a_document_closed_or_opened_in_a_run_follows_the_run() {
let closing = "vc.run('file.close', {index: 0}); vc.run('file.new', {}); vc.run('shape.rectangle', {x:1,y:1,width:1,height:1});";
let mut s = two_docs();
let before = state(&s);
assert!(run(&mut s, &format!("{closing} throw new Error('x');")).is_err());
assert_eq!(state(&s), before);
let first = s.documents()[0].uid;
run(&mut s, closing).unwrap();
assert_eq!(s.documents().len(), 2);
assert!(s.documents().iter().all(|d| d.uid != first));
assert_eq!(s.documents()[1].history.undo.len(), 0, "a document the script made starts with no undo steps");
}
#[test]
fn undo_in_a_run_reaches_only_the_runs_own_steps_and_old_ids_stay_dead() {
let mut s = two_docs();
let history = s.documents()[1].history.undo.len();
let source = "var p = vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}).id;\
vc.run('edit.undo');\
var q = vc.run('shape.rectangle', {x:2,y:2,width:1,height:1}).id;\
var why = '';\
try { vc.run('edit.clear', {ids: [p]}); } catch (e) { why = e.message; }\
vc.run('edit.undo');\
try { vc.run('edit.undo'); why += '|undid a step from before the run'; } catch (e) { why += '|stopped'; }\
vc.log({same: p === q, why: why});\
vc.run('shape.rectangle', {x:3,y:3,width:1,height:1});";
let r = run(&mut s, source).unwrap();
assert_eq!(r["log"][0]["same"], false, "{r}");
let why = r["log"][0]["why"].as_str().unwrap();
assert!(why.ends_with("|stopped") && why.len() > "|stopped".len(), "{why}");
assert_eq!(objects(&s, 1), 2);
assert_eq!(s.documents()[1].history.undo.len(), history + 1);
}
#[test]
fn a_string_over_the_crossing_limit_does_not_cross() {
let limits = vectorcraft_js::DataLimits { bytes: 20, ..vectorcraft_js::DataLimits::default() };
assert!(script::to_data(&json!("four"), &limits, 0, &mut (0, 0)).is_ok());
assert_eq!(script::to_data(&json!("fives"), &limits, 0, &mut (0, 0)), Err(String::new()));
}
#[test]
fn a_result_too_large_to_cross_names_its_place() {
let limits = vectorcraft_js::DataLimits { bytes: 90, ..vectorcraft_js::DataLimits::default() };
let v = json!({"a": [1, {"name": "x".repeat(40)}]});
assert_eq!(script::to_data(&v, &limits, 0, &mut (0, 0)), Err(".a[1].name".to_string()));
}
#[test]
fn ids_given_out_in_an_abandoned_run_are_not_given_out_again() {
let mut s = two_docs();
let before = s.active().unwrap().doc.peek_next_id();
paused(&mut s, &format!("{} while (true) {{}}", two_rect_script()));
assert!(s.script_abandon());
assert!(s.active().unwrap().doc.peek_next_id() > before);
}
#[test]
fn ids_given_out_in_a_failed_run_are_not_given_out_again() {
let mut s = two_docs();
let e =
run(&mut s, "var p = vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}).id; throw new Error('id=' + p + ';');").unwrap_err().to_string();
let used: u64 = e.split("id=").nth(1).unwrap().split(';').next().unwrap().parse().unwrap();
let dirty = s.documents()[1].is_dirty();
let id = s.execute("shape.rectangle", &rect(3)).unwrap()["id"].as_u64().unwrap();
assert!(id > used, "{id} after {used}");
assert!(dirty);
}
#[test]
fn a_rollback_keeps_the_history_after_an_undo_in_the_run() {
let mut s = two_docs();
let before = state(&s);
let source = "vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}); vc.run('edit.undo'); throw new Error('x');";
assert!(run(&mut s, source).is_err());
assert_eq!(state(&s), before);
}
#[test]
fn a_batch_joins_the_runs_one_step() {
let mut s = two_docs();
let undo = s.documents()[1].history.undo.len();
let source = "vc.run('command.batch', {commands: [\
{command:'shape.rectangle', params:{x:1,y:1,width:1,height:1}},\
{command:'shape.rectangle', params:{x:2,y:2,width:1,height:1}}]});";
run(&mut s, source).unwrap();
assert_eq!(s.documents()[1].history.undo.len(), undo + 1);
assert_eq!(objects(&s, 1), 3);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(objects(&s, 1), 1);
}
#[test]
fn a_run_with_nobody_to_ask_ends_at_its_growth_allowance() {
TEST_GROWTH.with(|g| g.set(Some(20)));
let mut s = two_docs();
let before = state(&s);
let source = "try { while (true) vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}); } catch (e) { vc.run('shape.rectangle', {x:2,y:1,width:1,height:1}); }";
let e = run(&mut s, source).unwrap_err().to_string();
TEST_GROWTH.with(|g| g.set(None));
assert!(e.contains("objects and data"), "{e}");
assert_eq!(state(&s), before);
}
#[test]
fn a_document_a_run_opens_counts_toward_its_growth_allowance() {
TEST_GROWTH.with(|g| g.set(Some(20)));
let rects: String = (0..30).map(|i| format!(r#"<rect x="{i}" y="0" width="1" height="1"/>"#)).collect();
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">{rects}</svg>"#);
let data = vectorcraft_format::base64_encode(svg.as_bytes());
let mut s = two_docs();
let before = state(&s);
let source = format!("try {{ vc.run('document.open', {{name: 'a.svg', dataBase64: '{data}'}}); }} catch (e) {{}}");
let e = run(&mut s, &source).unwrap_err().to_string();
assert!(e.contains("objects and data"), "{e}");
assert_eq!(state(&s), before);
// A document whose art is all in a symbol counts it too.
let mut maker = Session::new();
maker.execute("file.new", &json!({})).unwrap();
for x in 0..30 {
maker.execute("shape.rectangle", &rect(x)).unwrap();
}
maker.execute("select.all", &json!({})).unwrap();
let instance = maker.execute("symbol.new", &json!({"name": "Many"})).unwrap()["id"].as_u64().unwrap();
maker.execute("edit.clear", &json!({"ids": [instance]})).unwrap();
assert_eq!(objects(&maker, 0), 0);
let data = maker.execute("document.serialize", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string();
let source = format!("vc.run('document.open', {{name: 'b.vectorcraft', dataBase64: '{data}'}});");
let e = run(&mut s, &source).unwrap_err().to_string();
TEST_GROWTH.with(|g| g.set(None));
assert!(e.contains("objects and data"), "{e}");
assert_eq!(state(&s), before);
}
#[test]
fn graph_design_art_counts_toward_the_growth_allowance() {
TEST_GROWTH.with(|g| g.set(Some(20)));
let mut s = two_docs();
for x in 0..30 {
s.execute("shape.rectangle", &rect(x)).unwrap();
}
let before = state(&s);
let designs = |s: &Session| s.documents().iter().map(|d| d.doc.graph_designs.len()).collect::<Vec<_>>();
let designs_before = designs(&s);
// A design saved from the selection in an open document.
let e = run(&mut s, "vc.run('select.all'); vc.run('graph.design', {new: 'Many'});").unwrap_err().to_string();
assert!(e.contains("objects and data"), "{e}");
assert_eq!(state(&s), before);
assert_eq!(designs(&s), designs_before);
// A document a run opens whose art is all in a graph design.
let mut maker = Session::new();
maker.execute("file.new", &json!({})).unwrap();
let ids: Vec<u64> = (0..30).map(|x| maker.execute("shape.rectangle", &rect(x)).unwrap()["id"].as_u64().unwrap()).collect();
maker.execute("select.all", &json!({})).unwrap();
maker.execute("graph.design", &json!({"new": "Many"})).unwrap();
maker.execute("edit.clear", &json!({"ids": ids})).unwrap();
assert_eq!(objects(&maker, 0), 0);
assert_eq!(maker.documents()[0].doc.graph_designs[0].art.count_with_masks(), 31);
let data = maker.execute("document.serialize", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string();
let source = format!("vc.run('document.open', {{name: 'g.vectorcraft', dataBase64: '{data}'}});");
let e = run(&mut s, &source).unwrap_err().to_string();
TEST_GROWTH.with(|g| g.set(None));
assert!(e.contains("objects and data"), "{e}");
assert_eq!(state(&s), before);
}
/// Drops `v` one level at a time.
fn drop_flat(v: Value) {
let mut stack = vec![v];
while let Some(mut v) = stack.pop() {
match &mut v {
Value::Object(o) => stack.extend(std::mem::take(o).into_iter().map(|(_, x)| x)),
Value::Array(a) => stack.append(a),
_ => {}
}
}
}
#[test]
fn args_nested_ten_thousand_deep_in_a_script_throw_a_range_error_with_their_place() {
let mut s = two_docs();
let before = state(&s);
let source = "var v = 1; for (var i = 0; i < 10000; i++) v = {a: v}; var m = 'none'; \
try { vc.run('script.run', {source: '', args: v}); } catch (e) { m = e.name + '|' + e.message; } vc.log(m);";
let r = run(&mut s, source).unwrap();
let m = r["log"][0].as_str().unwrap_or_default();
assert!(m.starts_with("RangeError|") && m.contains(".args.a.a.a") && m.contains("nested too deep"), "{r}");
assert_eq!(state(&s), before);
}
#[test]
fn args_nested_ten_thousand_deep_from_a_caller_are_refused_with_their_place() {
let mut s = two_docs();
let before = state(&s);
// Built by moving each level in: `json!` would copy the value through `to_value`, one call per level.
let wrap = |key: &str, v: Value| Value::Object(std::iter::once((key.to_string(), v)).collect());
let mut deep = json!(1);
for _ in 0..10_000 {
deep = wrap("a", deep);
}
let mut params = wrap("args", wrap("b", deep));
params["source"] = json!("");
let e = s.execute("script.run", &params).unwrap_err().to_string();
drop_flat(params);
assert!(e.contains("`args.b.a.a.a") && e.contains("nested more than 64 levels deep"), "{e}");
assert_eq!(state(&s), before);
}
#[test]
fn a_person_can_let_a_growing_run_go_on_or_stop_it() {
TEST_GROWTH.with(|g| g.set(Some(20)));
let run_to_the_question = |s: &mut Session| {
s.script_frames = true;
run(s, "for (var i = 0; i < 25; i++) vc.run('shape.rectangle', {x:i,y:1,width:1,height:1});").unwrap();
let slices = Cell::new(0);
while !s.script_waiting() {
assert!(s.script_pump().is_none());
slices.set(slices.get() + 1);
assert!(slices.get() < 1_000);
}
// Waiting takes no fuel, however often the frame comes round.
assert!(s.script_pump().is_none());
};
let mut s = two_docs();
run_to_the_question(&mut s);
s.script_continue();
assert!(!s.script_waiting());
let done = loop {
if let Some(r) = s.script_pump() {
break r;
}
};
assert_eq!(done.unwrap()["ok"], true);
assert_eq!(objects(&s, 1), 26);
let mut s = two_docs();
let before = state(&s);
run_to_the_question(&mut s);
assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true);
assert!(!s.script_waiting());
assert!(s.script_pump().unwrap().is_err());
TEST_GROWTH.with(|g| g.set(None));
assert_eq!(state(&s), before);
}
#[test]
fn a_file_saved_before_a_failure_stays_written_and_is_named() {
let path = std::env::temp_dir().join(format!("vc-script-save-{}.svg", std::process::id()));
let _ = std::fs::remove_file(&path);
let p = path.to_string_lossy().into_owned();
let mut s = two_docs();
let before = state(&s);
let source =
format!("vc.run('file.saveAs', {{path: {p:?}}}); vc.run('shape.rectangle', {{x:1,y:1,width:1,height:1}}); throw new Error('later');");
let e = run(&mut s, &source).unwrap_err().to_string();
assert!(path.exists());
assert!(e.contains(&p), "{e}");
assert_eq!(s.script_report().unwrap()["written"], json!([p]));
assert_eq!(state(&s), before, "the document does not keep the new path");
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_committed_run_exports_what_it_drew() {
let path = std::env::temp_dir().join(format!("vc-script-export-{}.svg", std::process::id()));
let _ = std::fs::remove_file(&path);
let p = path.to_string_lossy().into_owned();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let source = "for (var i = 0; i < 7; i++) vc.run('shape.rectangle', {x:i*20,y:5,width:10,height:10});";
run(&mut s, source).unwrap();
s.execute("document.save", &json!({"path": p})).unwrap();
let svg = std::fs::read_to_string(&path).unwrap();
assert_eq!(svg.matches("<rect").count() + svg.matches("<path").count(), 7, "{svg}");
let inspected = s.execute("document.inspect", &json!({})).unwrap();
assert_eq!(inspected["layers"][0]["children"].as_array().map(Vec::len), Some(7));
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_run_needing_a_recovery_copy_it_cannot_write_does_not_start() {
let mut s = two_docs();
s.script_copy_first = true;
let before = state(&s);
let e = run(&mut s, "vc.run('shape.rectangle', {x:1,y:1,width:1,height:1});").unwrap_err().to_string();
assert!(e.contains("recovery copy"), "{e}");
assert_eq!(state(&s), before);
assert!(!s.script_running());
}
#[test]
fn a_run_does_not_start_while_a_complex_document_goes_without_a_copy() {
let store = std::sync::Arc::new(cmd::recovery::MemoryStore::default());
let mut s = Session::new();
s.recovery.set_store(store);
s.script_copy_first = true;
let mut d = vectorcraft_doc::Document::new(1000.0, 1000.0);
let layer = d.layers[0].id;
for i in 0..cmd::recovery::COMPLEX_OBJECTS {
let id = d.alloc_id();
let r = vectorcraft_geom::Rect::new((i % 200) as f64, (i / 200) as f64, (i % 200) as f64 + 1.0, (i / 200) as f64 + 1.0);
let n = vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art());
d.insert(Some(layer), i, n).unwrap();
}
s.add_document(d, None);
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
s.prefs.recovery_off_for_complex = true;
let e = run(&mut s, "1").unwrap_err().to_string();
assert!(e.contains("recovery copy"), "{e}");
assert!(!s.script_running());
s.prefs.recovery_off_for_complex = false;
assert_eq!(run(&mut s, "1").unwrap()["ok"], true);
}
#[test]
fn an_open_undo_group_stops_a_run_from_starting() {
let mut s = two_docs();
s.execute("edit.undoGroup.begin", &json!({})).unwrap();
assert!(run(&mut s, "1").unwrap_err().to_string().contains("undo group"));
s.execute("edit.undoGroup.end", &json!({})).unwrap();
assert_eq!(run(&mut s, "1").unwrap()["ok"], true);
}
#[test]
fn a_cancel_from_the_caller_stops_a_headless_run() {
let mut s = two_docs();
let before = state(&s);
let stop = s.script_interrupt();
stop.set();
assert!(s.execute("script.run", &json!({"source": "1", "maxFuel": 10_000})).is_ok(), "a run clears a flag left from before");
assert!(!stop.is_set());
assert_eq!(state(&s), before);
}
/// Script text: pieces that change documents, throw or loop, mixed with arbitrary text.
fn source_text() -> impl proptest::strategy::Strategy<Value = String> {
use proptest::prelude::*;
let piece = prop_oneof![
Just("vc.run('shape.rectangle', {x:1,y:1,width:5,height:5});".to_string()),
Just("vc.run('select.all'); vc.run('edit.clear');".to_string()),
Just("vc.run('file.new', {});".to_string()),
Just("vc.run('document.activate', {index:0});".to_string()),
Just("vc.run('no.such');".to_string()),
Just("vc.log(vc.document);".to_string()),
Just("throw new Error('x');".to_string()),
Just("vc.run('shape.rectangle', {x:2,y:2,width:5,height:5}); throw new Error('y');".to_string()),
Just("while (true) {}".to_string()),
Just("var o = {}; o.o = o; vc.run('shape.rectangle', o);".to_string()),
Just("{".to_string()),
Just("try {".to_string()),
Just("} catch (e) {}".to_string()),
"[ -~]{0,30}",
"\\PC{0,10}",
];
proptest::collection::vec(piece, 0..12).prop_map(|p| p.concat())
}
/// Every field of each open document but its next id, and the rest of what a rollback puts back.
/// A failed run raises the next id past the ids it gave out, which [`next_ids`] reads.
fn saved(s: &Session) -> (Value, Vec<String>) {
let docs = s
.documents()
.iter()
.map(|d| {
let mut doc = (*d.doc).clone();
doc.raise_next_id(1 << 50);
format!("{doc:?}")
})
.collect();
(state(s), docs)
}
fn next_ids(s: &Session) -> Vec<u64> {
s.documents().iter().map(|d| d.doc.peek_next_id()).collect()
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(48))]
/// Any script text ends with a report or a script error, and a run that fails leaves every
/// document as it was.
#[test]
fn any_source_ends_with_a_report_or_a_script_error(source in source_text()) {
let mut s = two_docs();
let (before, ids) = (saved(&s), next_ids(&s));
match s.execute("script.run", &json!({"source": source, "maxFuel": 200_000})) {
Ok(r) => proptest::prop_assert_eq!(&r["ok"], &json!(true), "{}", r),
Err(e) => {
proptest::prop_assert!(matches!(e, EngineError::Other(_)), "{:?}", e);
let kind = s.script_report.as_ref().map(|r| r["kind"].clone());
proptest::prop_assert!(kind.as_ref().is_some_and(|k| k != "internal"), "{:?}: {}", kind, e);
proptest::prop_assert_eq!(saved(&s), before);
proptest::prop_assert!(next_ids(&s).iter().zip(&ids).all(|(after, was)| after >= was), "{:?} after {:?}", next_ids(&s), ids);
}
}
}
}
+17
View File
@@ -6,6 +6,14 @@ use serde_json::{Value, json};
use crate::cmd::spelling::MAX_DICTIONARY_FILE_BYTES;
use crate::*;
/// Held by each test that changes the custom dictionary, which the whole process shares: a save
/// writes every word in it to the test's file and a reload takes the file's words back, so another
/// test's word could come back after that test deleted it.
fn dictionary_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A session with a new 400 × 400 document.
fn session() -> Session {
let mut s = Session::new();
@@ -115,6 +123,7 @@ fn marked(s: &mut Session) -> Vec<String> {
#[test]
fn added_words_are_right_in_every_check_until_deleted() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
text(&mut s, 50.0, "drawing Zqvwkx tools");
assert_eq!(marked(&mut s), ["Zqvwkx"]);
@@ -137,6 +146,7 @@ fn added_words_are_right_in_every_check_until_deleted() {
#[test]
fn a_changed_word_replaces_the_old_one() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
text(&mut s, 50.0, "xkvzqa xkvzqb");
run(&mut s, "text.spelling.addWord", json!({"word": "xkvzqa"}));
@@ -150,6 +160,7 @@ fn a_changed_word_replaces_the_old_one() {
#[test]
fn the_custom_dictionary_file_is_read_and_kept_up_to_date() {
let _one_at_a_time = dictionary_lock();
let dir = vectorcraft_testkit::temp_dir("spelling-dictionary");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "kqzvwx\nnot one word\n\n").unwrap();
@@ -170,6 +181,7 @@ fn the_custom_dictionary_file_is_read_and_kept_up_to_date() {
#[test]
fn ignore_all_skips_a_word_in_the_active_document_only() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
text(&mut s, 50.0, "Wqzxkv drawing wqzxkv vqzxkw");
run(&mut s, "text.spelling.ignoreAll", json!({"word": "WQZXKV"}));
@@ -183,6 +195,7 @@ fn ignore_all_skips_a_word_in_the_active_document_only() {
#[test]
fn custom_dictionary_commands_refuse_what_isnt_one_word() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
let long = "q".repeat(65);
for (id, p) in [
@@ -210,6 +223,7 @@ fn in_dictionary(s: &mut Session, word: &str) -> bool {
#[test]
fn a_dictionary_file_that_cant_be_read_whole_is_never_written_over() {
let _one_at_a_time = dictionary_lock();
let dir = vectorcraft_testkit::temp_dir("spelling-unreadable");
// A folder where the file should be can't be read as one; a file past what craft-proof reads
// would lose its other words if written back.
@@ -231,6 +245,7 @@ fn a_dictionary_file_that_cant_be_read_whole_is_never_written_over() {
#[test]
fn a_change_that_cant_be_saved_is_taken_back() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
run(&mut s, "text.spelling.addWord", json!({"word": "wkqzvx"}));
let dir = vectorcraft_testkit::temp_dir("spelling-rollback");
@@ -248,6 +263,7 @@ fn a_change_that_cant_be_saved_is_taken_back() {
#[cfg(unix)]
#[test]
fn a_word_another_window_saved_stays_when_this_save_fails() {
let _one_at_a_time = dictionary_lock();
use std::os::unix::fs::PermissionsExt;
let dir = vectorcraft_testkit::temp_dir("spelling-readonly");
let file = dir.join("Custom Dictionary.txt");
@@ -296,6 +312,7 @@ fn misspellings_of_one_object_are_what_the_check_lists() {
/// what Auto Spell Check found before is checked again.
#[test]
fn custom_dictionary_changes_move_its_generation_on() {
let _one_at_a_time = dictionary_lock();
use crate::cmd::spelling::dictionary_generation;
let mut s = session();
let mut last = dictionary_generation();
+31 -1
View File
@@ -84,12 +84,16 @@ 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(), 2, "{libs:?}");
assert_eq!(libs.len(), 3, "{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()),
(Some("ui-frames"), Some("UI Frames"), Some("builtIn"))
);
assert_eq!(
(libs[2]["id"].as_str(), libs[2]["name"].as_str(), libs[2]["category"].as_str()),
(Some("map-markers"), Some("Map Markers"), 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"]);
@@ -125,6 +129,32 @@ fn ui_frames_symbols_carry_default_nine_slice_when_added() {
));
}
#[test]
fn map_markers_symbols_add_without_nine_slice() {
let mut s = session();
let lib = run(&mut s, "symbol.library", json!({"library": "map-markers"}));
let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Pin", "Flag", "Crosshair", "Checkpoint"]);
let mut placed = Vec::new();
for (i, name) in names.iter().enumerate() {
let r = run(
&mut s,
"symbol.addFromLibrary",
json!({"library": "map-markers", "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 == "Pin"
));
}
#[test]
fn a_saved_library_reopens_and_its_symbols_join_another_document_in_one_step() {
let dir = folder("user");
+325
View File
@@ -0,0 +1,325 @@
//! Preferences › Type › Measure Type by Glyph Bounds (`typeGlyphBounds`): the bounding box, the
//! Transform panel's values and `object.setBounds` measure each point type object selected as an
//! object (not inside a selected group) by the ink of its glyphs instead of its em box.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind, OrientedBox, TextObject};
use vectorcraft_geom::Rect;
use crate::*;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn id(v: Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// A new document holding point type made by `text.create` with `params`, selected.
fn with_type(params: Value) -> (Session, NodeId) {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 800, "height": 800}));
let t = id(run(&mut s, "text.create", params));
run(&mut s, "select.set", json!({"ids": [t.0]}));
(s, t)
}
fn glyphs(s: &mut Session, on: bool) {
run(s, "prefs.set", json!({"values": {"typeGlyphBounds": on}}));
}
fn text(s: &Session, id: NodeId) -> TextObject {
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("{id:?} is not type") };
(**t).clone()
}
/// The ink of the type's glyphs on the page, laid out afresh (not from the cache).
fn ink(s: &Session, id: NodeId) -> Rect {
let t = text(s, id);
t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap())
}
/// The em box (the line box) of point type: what the bounding box measures by default.
fn em(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn near(a: Rect, b: Rect, tol: f64) -> bool {
(a.x0 - b.x0).abs() <= tol && (a.y0 - b.y0).abs() <= tol && (a.x1 - b.x1).abs() <= tol && (a.y1 - b.y1).abs() <= tol
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// The glyph box of `c` in Inter Regular, in font units (x min, y min, x max, y max), and the
/// font's units per em, read from the font file's own `head`, `cmap`, `loca` and `glyf` tables.
fn inter_glyph_box(c: char) -> ([f64; 4], f64) {
use skrifa::MetadataProvider;
use skrifa::raw::TableProvider;
let font = skrifa::FontRef::new(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")).unwrap();
let upem = f64::from(font.head().unwrap().units_per_em());
let gid = font.charmap().map(c).unwrap();
let glyph = font.loca(None).unwrap().get_glyf(gid, &font.glyf().unwrap()).unwrap().unwrap();
([glyph.x_min(), glyph.y_min(), glyph.x_max(), glyph.y_max()].map(f64::from), upem)
}
/// "H" in Inter at 100 pt is drawn with straight lines only, so the box of its outline is the glyph
/// box the font file stores: the bounding box takes its size and place from the font itself.
#[test]
fn point_type_measures_the_glyph_box_the_font_file_stores() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "font": "Inter", "text": "H"}));
glyphs(&mut s, true);
let ([x0, y0, x1, y1], upem) = inter_glyph_box('H');
let k = 100.0 / upem;
let b = s.transform_box(&[t]).unwrap();
assert_eq!(b.angle, 0.0);
// Text space runs down the page from the baseline at y = 300.
let want = Rect::new(100.0 + x0 * k, 300.0 - y1 * k, 100.0 + x1 * k, 300.0 - y0 * k);
assert!(near(b.rect, want, 1e-6), "{:?} != {want:?}", b.rect);
assert!(near(s.transform_bounds(&[t]).unwrap(), want, 1e-6));
// The em box is taller than the cap height.
assert!(em(&s, t).height() > b.rect.height() + 10.0, "em box {:?}", em(&s, t));
}
#[test]
fn off_by_default_the_box_is_the_em_box() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
assert!(!s.prefs.type_glyph_bounds);
assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t));
glyphs(&mut s, true);
assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-9));
glyphs(&mut s, false);
assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t));
}
/// A typed height scales the type so its ink takes that height, in one undo step, and the type
/// stays type.
#[test]
fn set_bounds_gives_the_ink_the_typed_size() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
glyphs(&mut s, true);
let xf = text(&s, t).xf;
let n = undo_len(&s);
run(&mut s, "object.setBounds", json!({"height": 50}));
assert_eq!(undo_len(&s), n + 1);
assert!((ink(&s, t).height() - 50.0).abs() < 1e-6, "{:?}", ink(&s, t));
assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-6));
assert!(matches!(s.doc().unwrap().doc.node(t).unwrap().kind, NodeKind::Text(_)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s, t).xf, xf);
}
/// A typed X moves the ink box's reference point there and keeps its size; a typed width gives
/// the ink that width.
#[test]
fn set_bounds_moves_and_sizes_the_ink_box() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
glyphs(&mut s, true);
let before = ink(&s, t);
run(&mut s, "object.setBounds", json!({"x": 400, "reference": 0}));
let moved = ink(&s, t);
assert!((moved.x0 - 400.0).abs() < 1e-6 && (moved.y0 - before.y0).abs() < 1e-6, "{moved:?}");
assert!((moved.width() - before.width()).abs() < 1e-6 && (moved.height() - before.height()).abs() < 1e-6, "{moved:?}");
run(&mut s, "object.setBounds", json!({"width": 123}));
assert!((ink(&s, t).width() - 123.0).abs() < 1e-6, "{:?}", ink(&s, t));
assert!((ink(&s, t).height() - before.height()).abs() < 1e-6, "{:?}", ink(&s, t));
}
#[test]
fn set_bounds_with_the_current_values_records_no_undo_step() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
glyphs(&mut s, true);
let b = s.transform_box(&[t]).unwrap().rect;
let n = undo_len(&s);
run(&mut s, "object.setBounds", json!({"x": b.center().x, "y": b.center().y, "width": b.width(), "height": b.height()}));
assert_eq!(undo_len(&s), n);
assert_eq!(s.transform_box(&[t]).unwrap().rect, b);
}
/// A proportional width scales both sides of the ink by the same factor, about the reference
/// point of the ink box (here its top-left corner).
#[test]
fn proportional_width_keeps_the_ink_in_proportion_about_the_reference_point() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
glyphs(&mut s, true);
let before = ink(&s, t);
run(&mut s, "object.setBounds", json!({"width": before.width() * 2.0, "proportional": true, "reference": 0}));
let after = ink(&s, t);
assert!((after.width() - before.width() * 2.0).abs() < 1e-6, "{after:?}");
assert!((after.height() - before.height() * 2.0).abs() < 1e-6, "{after:?}");
assert!((after.x0 - before.x0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} -> {after:?}");
}
/// Rotated type keeps its own angle, and the box is the size of the ink before the rotation. A
/// skewed object's box is the box of its glyph outlines, not of its mapped ink rectangle.
#[test]
fn rotated_and_skewed_type_measure_their_outlines() {
let (mut s, flat) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
glyphs(&mut s, true);
let size = s.transform_box(&[flat]).unwrap().rect.size();
let t = id(run(&mut s, "text.create", json!({"x": 300, "y": 300, "size": 100, "text": "Hg", "rotation": 30})));
let b = s.transform_box(&[t]).unwrap();
assert_eq!(b.angle, s.doc().unwrap().doc.oriented_bounds(&[t], false).unwrap().angle);
assert!(b.angle != 0.0);
assert!((b.rect.width() - size.width).abs() < 1e-6 && (b.rect.height() - size.height).abs() < 1e-6, "{b:?} vs {size:?}");
let o = id(run(&mut s, "text.create", json!({"x": 100, "y": 600, "size": 100, "text": "o"})));
run(&mut s, "select.set", json!({"ids": [o.0]}));
run(&mut s, "object.transform", json!({"matrix": [1, 0, 0.6, 1, -360, 0]}));
let b = s.transform_box(&[o]).unwrap();
let tx = text(&s, o);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx);
let to_local = OrientedBox { angle: b.angle, rect: Rect::ZERO }.to_doc().inverse();
let want = lay.ink_bounds_in(to_local * tx.xf).unwrap();
assert!(near(b.rect, want, 1e-9), "{:?} != {want:?}", b.rect);
let mapped = (to_local * tx.xf).transform_rect_bbox(lay.ink_bounds().unwrap());
assert!(mapped.width() > b.rect.width() + 1.0, "a round letter's outline is narrower than its slanted box");
}
/// Type that draws nothing is measured by its em box.
#[test]
fn empty_and_space_only_type_keep_the_em_box() {
for text in ["", " "] {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 40, "text": text}));
glyphs(&mut s, true);
assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t), "{text:?}");
}
}
/// Area type keeps its frame, and type on a path its path.
#[test]
fn area_type_and_type_on_a_path_are_measured_as_before() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 800, "height": 800}));
let area = id(run(&mut s, "text.create", json!({"x": 100, "y": 100, "size": 24, "text": "ace", "area": {"width": 200, "height": 100}})));
let line = id(run(&mut s, "shape.line", json!({"x1": 100, "y1": 400, "x2": 500, "y2": 400})));
let path = id(run(&mut s, "text.createInPath", json!({"path": line.0, "mode": "onPath", "text": "ace", "size": 24})));
for t in [area, path] {
let off = s.transform_box(&[t]).unwrap();
glyphs(&mut s, true);
assert_eq!(s.transform_box(&[t]).unwrap(), off, "{t:?}");
glyphs(&mut s, false);
}
}
#[test]
fn vertical_point_type_measures_its_ink() {
let (mut s, t) = with_type(json!({"x": 100, "y": 100, "size": 60, "text": "ace", "vertical": true}));
glyphs(&mut s, true);
let b = s.transform_box(&[t]).unwrap().rect;
assert!(near(b, ink(&s, t), 1e-9), "{b:?} != {:?}", ink(&s, t));
assert!(em(&s, t).width() > b.width() + 1.0, "em box {:?}", em(&s, t));
}
/// With Use Preview Bounds, the characters' strokes paint around the glyphs.
#[test]
fn preview_bounds_add_the_character_strokes_to_the_ink() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
run(&mut s, "text.setStyle", json!({"ids": [t.0], "stroke": "#000000", "strokeWidth": 6}));
run(&mut s, "prefs.set", json!({"values": {"typeGlyphBounds": true, "usePreviewBounds": true}}));
let o = text(&s, t).char_stroke_outset();
assert!(o > 0.0);
let b = s.transform_box(&[t]).unwrap().rect;
assert!(near(b, ink(&s, t).inflate(o, o), 1e-9), "{b:?}");
// A typed height is the height with the strokes.
run(&mut s, "object.setBounds", json!({"height": 80}));
assert!((s.transform_box(&[t]).unwrap().rect.height() - 80.0).abs() < 1e-6, "{:?}", s.transform_box(&[t]));
}
/// Type inside a selected group is measured as before: the group's box does not change.
#[test]
fn a_group_holding_type_keeps_its_box() {
let (mut s, _) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
let g = id(run(&mut s, "object.group", json!({})));
let off = s.transform_box(&[g]).unwrap();
glyphs(&mut s, true);
assert_eq!(s.transform_box(&[g]).unwrap(), off);
}
/// Runs in two fonts: the box covers the ink of both, and follows an edit of the type.
#[test]
fn mixed_fonts_measure_the_ink_of_every_run() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hgxo"}));
glyphs(&mut s, true);
run(&mut s, "text.setRangeStyle", json!({"id": t.0, "font": "Source Serif 4", "start": 2, "end": 4}));
let tx = text(&s, t);
assert!(tx.runs.iter().any(|r| r.style.font_family == "Source Serif 4") && tx.runs.len() > 1, "{:?}", tx.runs);
assert!(tx.ink().is_some(), "the edit refreshed the cached ink");
assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-9));
}
/// A selection of type and a shape is measured by the type's ink and the shape's bounds.
#[test]
fn a_mixed_selection_takes_the_ink_and_the_shapes_bounds() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
let r = id(run(&mut s, "shape.rectangle", json!({"x": 500, "y": 500, "width": 50, "height": 50})));
glyphs(&mut s, true);
assert!(near(s.transform_bounds(&[t, r]).unwrap(), ink(&s, t).union(Rect::new(500.0, 500.0, 550.0, 550.0)), 1e-9));
}
#[test]
fn the_preference_is_checked_and_saved() {
let mut s = Session::new();
assert!(s.execute("prefs.set", &json!({"values": {"typeGlyphBounds": "yes"}})).is_err());
assert!(!s.prefs.type_glyph_bounds);
assert!(!serde_json::from_value::<Prefs>(json!({})).unwrap().type_glyph_bounds);
run(&mut s, "prefs.set", json!({"key": "typeGlyphBounds", "value": true}));
let saved = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(saved["typeGlyphBounds"], json!(true));
assert!(serde_json::from_value::<Prefs>(saved).unwrap().type_glyph_bounds);
assert_eq!(run(&mut s, "prefs.get", json!({"key": "typeGlyphBounds"})), json!(true));
}
/// The preference is not part of the document: a saved document is the same with it on or off.
#[test]
fn saved_documents_do_not_depend_on_the_preference() {
let (mut s, _) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
let off = serde_json::to_string(&s.doc().unwrap().doc).unwrap();
assert!(!off.contains("cached_ink") && !off.contains("cachedInk"), "the cached ink is not saved");
glyphs(&mut s, true);
assert_eq!(serde_json::to_string(&s.doc().unwrap().doc).unwrap(), off);
}
/// What the type draws on the page: its glyphs and its underline and strikethrough bars.
fn drawn(s: &Session, id: NodeId) -> Rect {
let t = text(s, id);
vectorcraft_text::envelope_ink(&t, t.xf, None).unwrap()
}
/// Underline and strikethrough bars are drawn with the type, so the box takes them in and a typed
/// height gives what is drawn that height. Underlined spaces are measured by their bar.
#[test]
fn underline_and_strikethrough_bars_count_as_ink() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "H"}));
run(&mut s, "text.setRangeStyle", json!({"id": t.0, "start": 0, "end": 1, "underline": true}));
glyphs(&mut s, true);
let b = s.transform_box(&[t]).unwrap().rect;
assert!(near(b, drawn(&s, t), 1e-9), "{b:?} != {:?}", drawn(&s, t));
assert!(b.y1 > ink(&s, t).y1 + 1.0, "the underline hangs below the glyph");
run(&mut s, "object.setBounds", json!({"height": 50}));
assert!((drawn(&s, t).height() - 50.0).abs() < 1e-6, "{:?}", drawn(&s, t));
assert!(near(s.transform_box(&[t]).unwrap().rect, drawn(&s, t), 1e-6));
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 40, "text": " "}));
run(&mut s, "text.setRangeStyle", json!({"id": t.0, "start": 0, "end": 3, "strikethrough": true}));
glyphs(&mut s, true);
let b = s.transform_box(&[t]).unwrap().rect;
assert!(near(b, drawn(&s, t), 1e-9), "{b:?} != {:?}", drawn(&s, t));
assert!(em(&s, t).height() > b.height() + 10.0, "em box {:?}", em(&s, t));
}
/// A transform of type keeps its cached ink, which is in text space.
#[test]
fn the_cached_ink_is_the_laid_out_ink() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
let tx = text(&s, t);
assert_eq!(tx.ink(), Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds()));
run(&mut s, "object.transform", json!({"matrix": [2, 0, 0, 3, 0, 0]}));
assert_eq!(text(&s, t).ink(), tx.ink());
// A cache measured with other bounds is not read.
let mut stale = text(&s, t);
stale.cached_bounds = Some(Rect::new(0.0, 0.0, 1.0, 1.0));
assert_eq!(stale.ink(), None);
}
+16
View File
@@ -365,6 +365,7 @@ impl Session {
freeform_point: self.selected_freeform_point(),
raster_sample: self.prefs.eyedropper.sample_size,
preview_bounds: self.prefs.use_preview_bounds,
glyph_bounds: self.prefs.type_glyph_bounds,
preview_memo: Some(&self.preview_memo),
unit,
stroke_unit,
@@ -455,6 +456,9 @@ impl Session {
/// applied, in order ([`Session::tool_poll`]); moves of the pointer with no button held go to
/// the tool at once.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
if self.script_running() {
return if ev.kind == PointerKind::Move { Ok(vec![]) } else { Err(EngineError::Busy) };
}
let n = self.next_input();
if ev.kind != PointerKind::Move && (!self.held_input.is_empty() || self.tool_edit_pending()) {
self.held_input.push_back((n, HeldInput::Pointer(*ev), view));
@@ -877,6 +881,9 @@ impl Session {
/// A key for the active tool. While the tool computes an edit on another thread, the key is held
/// and made once the edit is applied, after the input held before it.
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
if self.script_running() {
return Err(EngineError::Busy);
}
let n = self.next_input();
if self.tool_editing() {
self.held_input.push_back((n, HeldInput::Key(key, mods), view));
@@ -910,6 +917,9 @@ impl Session {
/// Typed text for the active tool (Type tool).
pub fn tool_text(&mut self, text: &str, view: ViewInfo) -> Result<Vec<UiRequest>> {
if self.script_running() {
return Err(EngineError::Busy);
}
let acts = self.with_tool_cx(view, |t, cx| t.text_input(cx, text));
self.take_tool_panic()?;
self.apply_actions(acts)
@@ -1059,6 +1069,12 @@ impl Session {
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
}
/// Has the active tool started something that Escape ends while no button is pressed (see
/// `Tool::ends_on_escape`)?
pub fn tool_ends_on_escape(&mut self, view: ViewInfo) -> bool {
self.with_tool_cx(view, |t, cx| t.ends_on_escape(cx))
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
// The Blend tool's first pick is not marked after an edit that dropped it.
self.tell_tool_history_moved();
+2
View File
@@ -37,6 +37,8 @@ use crate::ps;
pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN;
#[cfg(test)]
pub(crate) use envelope::MAX_CHECKED;
#[cfg(test)]
pub(crate) use envelope::TYPE_DRAWN as ENVELOPE_TYPE_DRAWN;
pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType};
pub(crate) use frames::text_frames;
pub use obj::{NameEncoding, Names};
+8 -5
View File
@@ -35,12 +35,15 @@ use vectorcraft_doc::{Appearance, Document, Effect, ImageBlob, LayerColor, Node,
use vectorcraft_geom::PathData;
use super::obj::Obj;
use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compare_scale, differ, differ_in_colour};
use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compare_scale, differ, differ_in_colour, differ_on_black};
/// The plugin that writes envelopes.
pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use
/// The note for an envelope that keeps its drawn art.
pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live envelope comes in as its drawn art";
/// The note for an envelope of type, which keeps its drawn art with its type hidden (unless the
/// text document gives the type nothing, which a note of its own then says).
pub(crate) const TYPE_DRAWN: &str = "type in an envelope comes in as the envelope's drawn art, not as editable type";
/// The note for warped type that keeps its drawn art, before how many.
pub(crate) const WARPED_TYPE_DRAWN: &str = "warped text objects that came in as drawn art, not as editable type";
/// The most envelopes waiting for their drawn art at once.
@@ -325,9 +328,9 @@ fn alone(node: &Node, layer: NodeId, shared: &Shared<'_>) -> Document {
}
/// How `live` and `drawn`, drawn over `region` at `scale`, differ: the pixels that differ in
/// lightness or colour, those whose neighbours all differ too (where one draws something the other
/// doesn't, not an edge drawn a little apart), and the pixels either inks. `None` when they can't
/// be drawn.
/// lightness or colour, seen on white or on black (each is drawn on its own: white art shows only on
/// black), those whose neighbours all differ too (where one draws something the other doesn't, not
/// an edge drawn a little apart), and the pixels either inks. `None` when they can't be drawn.
fn differing(live: &Document, drawn: &Document, region: Rect, scale: f64) -> Option<(usize, usize, usize)> {
let x = vectorcraft_render::Renderer::new().render_region(live, region, scale, false);
let y = vectorcraft_render::Renderer::new().render_region(drawn, region, scale, false);
@@ -336,7 +339,7 @@ fn differing(live: &Document, drawn: &Document, region: Rect, scale: f64) -> Opt
}
let (w, h) = (x.width as usize, x.height as usize);
let (xs, ys) = (x.pixels.as_chunks::<4>().0, y.pixels.as_chunks::<4>().0);
let differs: Vec<bool> = xs.iter().zip(ys).map(|(p, q)| differ(p, q) || differ_in_colour(p, q)).collect();
let differs: Vec<bool> = xs.iter().zip(ys).map(|(p, q)| differ(p, q) || differ_in_colour(p, q) || differ_on_black(p, q)).collect();
let inked = xs.iter().zip(ys).filter(|(p, q)| p[3] > 8 || q[3] > 8).count();
let at = |i: usize, j: usize| differs.get(j * w + i).copied().unwrap_or(false);
let any = differs.iter().filter(|d| **d).count();
@@ -519,6 +519,7 @@ impl<'a> Reader<'a> {
// An envelope of type keeps its drawn art and its type, hidden, as above: the
// type is filled in after reading, which the envelope can't wait for.
if typed {
self.warn(crate::import::ai::envelope::TYPE_DRAWN);
return Ok(());
}
if self.envelopes.len() >= crate::import::ai::envelope::MAX_PENDING {
+47
View File
@@ -7826,6 +7826,23 @@ fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
assert_eq!(r.warnings, [super::envelope::DRAWN]);
}
/// The same, in white: white art seen on white looks like nothing, so the check sees it on black
/// too, and a white envelope that draws elsewhere keeps its drawn art.
#[test]
fn a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
use vectorcraft_doc::live::EnvelopeKind;
const WHITE: &str = "0 0 0 0 1 1 1 Xa\n";
let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true };
let content = envelope_content().replace(RED, WHITE);
let drawn = drawn_content(&other).replace(RED, WHITE);
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &content, 1, &drawn);
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}");
assert_eq!(r.warnings, [super::envelope::DRAWN]);
}
/// A document of `n` warped text objects, with the art the live warp makes of each, in its look.
fn warped_type_doc(n: usize) -> (Document, Vec<super::envelope::WarpedLook>) {
use vectorcraft_doc::{Appearance, Effect, TextObject};
@@ -7871,6 +7888,36 @@ fn warped_type_past_the_budget_comes_in_as_its_look() {
assert_eq!(kept.extras().wrap, Some(wrap));
}
/// White warped type is checked as other type is: seen on white it looks like nothing, so the check
/// sees it on black too. Type whose live warp draws as its look stays type; type whose look is
/// warped the other way comes in as its look.
#[test]
fn white_warped_type_that_draws_elsewhere_comes_in_as_its_look() {
use vectorcraft_doc::{Appearance, CharStyle, Effect, TextObject};
let warp = |bend: f64| {
let mut appearance = Appearance::default();
appearance.effects.push(Effect { id: "warp.arc".into(), params: serde_json::json!({"bend": bend}), visible: true });
appearance
};
for (look_bend, replaced) in [(40.0, 0), (-40.0, 1)] {
let mut doc = Document::new(400.0, 200.0);
let mut layer = Node::layer(doc.alloc_id(), "Art", vectorcraft_doc::LayerColor::Preset(0));
let white = CharStyle { fill: Paint::solid(Color::WHITE), ..Default::default() };
let t = TextObject::point(kurbo::Point::new(20.0, 60.0), "TILT", white);
let mut text = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
text.appearance = warp(40.0).into();
let mut other = text.clone();
other.appearance = warp(look_bend).into();
let mut look = vectorcraft_effects::expand_outlined(None, &other, None).unwrap();
look.id = doc.alloc_id();
let looks = vec![super::envelope::WarpedLook { slot: text.id, look, left_out: None }];
layer.children_mut().unwrap().push(Arc::new(text));
doc.layers = vec![Arc::new(layer)];
let warped = super::envelope::WarpedType { looks, checks: 1, ..Default::default() };
assert_eq!(warped.settle(&mut doc, Some, &Default::default(), &mut vec![]), replaced, "look bent {look_bend}");
}
}
/// The budget for warped type is what the envelopes left: their checks and the page's share they
/// spent, with the page they are held to.
#[test]
+9
View File
@@ -56,6 +56,15 @@ pub(in crate::import) fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool {
lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL)
}
/// Do two pixels look different seen on black: in lightness? An object drawn on its own and seen
/// only on white hides what it draws in white (white type over nothing looks like nothing): seen
/// on black too, that shows.
pub(in crate::import) fn differ_on_black(p: &[u8; 4], q: &[u8; 4]) -> bool {
// Premultiplied, a pixel is what it shows on black.
let on_black = |p: &[u8; 4]| [0, 1, 2].map(|i| u32::from(p[i]));
p != q && lightness(on_black(p)).abs_diff(lightness(on_black(q))) > SAME_PIXEL
}
/// How far apart two pixels' red, green or blue may be for them to be the same colour (a colour
/// and its CMYK equivalent stay within it; a red and a dark red don't).
const SAME_COLOUR: u32 = 64;
+2 -1
View File
@@ -5222,7 +5222,8 @@ fn envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance()
}
});
assert_eq!(texts, [("CHONKYCATS".to_string(), false)], "{:?}", r.warnings);
assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{:?}", r.warnings);
// The notes say the type isn't editable.
assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings);
}
/// A live envelope that draws a little apart from its drawn art, in layers that already differ
+1 -1
View File
@@ -501,7 +501,7 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) ->
return Ok(Value::String(Str::from_units(units.to_vec())));
}
Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"),
Some(Func::Host { name, .. }) => format!("function {name}() {{ [native code] }}"),
Some(Func::Host { name, .. } | Func::Data { name, .. }) => format!("function {name}() {{ [native code] }}"),
Some(Func::Bound { .. }) => "function () { [native code] }".to_string(),
None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")),
};
+4
View File
@@ -158,6 +158,9 @@ pub(crate) type NativeFn = fn(&mut crate::interp::Interp, Value, &[Value], bool)
/// A function the embedding defines.
pub(crate) type HostFn = dyn Fn(&[crate::JsValue]) -> Result<crate::JsValue, String>;
/// A function the embedding defines that takes and gives structured values.
pub(crate) type DataFn = crate::host::HostFunction;
/// The built-ins the interpreter treats specially when it calls them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum NativeKind {
@@ -177,6 +180,7 @@ 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> },
Bound { target: ObjId, this: Value, args: Rc<[Value]> },
}
+351
View File
@@ -0,0 +1,351 @@
//! Structured values between a script and its host: [`Data`] (JSON's shapes and `undefined`), the
//! checks a value passes on its way across, and the frozen objects a host adds to the global
//! environment.
//!
//! A value read from a script may hold plain objects, arrays, strings, finite numbers, booleans,
//! `null` and `undefined`. A cycle, a value too deep or too large, a function, a getter, a date, a
//! regular expression, a boxed primitive, a hole in an array and a number that is not finite are
//! refused with a `TypeError` or `RangeError` naming where the value is. The same object may appear
//! more than once when it is not its own ancestor; each appearance counts against the size limit,
//! so sharing cannot multiply the work without limit.
use std::rc::Rc;
use crate::LimitKind;
use crate::heap::{Class, ENUMERABLE, Func, Key, OBJECT_BYTES, ObjId, Object, PROP_BYTES, Prop, key, string_bytes};
use crate::interp::{Abrupt, ErrorType, Interp, JsResult};
use crate::value::{Value, index_key};
/// A value that crosses between a script and the host.
#[derive(Clone, Debug, PartialEq)]
pub enum Data {
Undefined,
Null,
Bool(bool),
Number(f64),
String(String),
Array(Vec<Data>),
/// An object's own enumerable properties, in the order the object lists them.
Object(Vec<(String, Data)>),
}
/// How much one crossing may carry.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DataLimits {
/// How deeply arrays and objects may nest.
pub depth: usize,
/// Estimated bytes of the strings, names and numbers.
pub bytes: usize,
/// Array elements and object properties, all of them counted together.
pub entries: usize,
}
impl Default for DataLimits {
fn default() -> Self {
Self { depth: 64, bytes: 16 << 20, entries: 1 << 20 }
}
}
/// How a host function fails.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HostError {
/// Thrown to the script as an `Error`; the script can catch it.
Error(String),
/// Thrown to the script as a `TypeError`.
Type(String),
/// Thrown to the script as a `RangeError`.
Range(String),
/// Ends the run with [`LimitKind::Host`] and this message. The script cannot catch it.
Abort(String),
}
/// A function the host gives a script, with its arguments converted.
pub type HostFunction = Rc<dyn Fn(Vec<Data>) -> Result<Data, HostError>>;
/// A member of an object the host adds to the global environment.
#[derive(Clone)]
pub enum HostItem {
/// A read-only copy of a value.
Value(Data),
Function(HostFunction),
/// A property whose value the function gives each time it is read.
Getter(HostFunction),
/// A frozen object with these members.
Object(Vec<(String, HostItem)>),
}
enum Step {
Name(Key),
Index(u32),
}
/// A value being read: what it has used so far, and where the reader is.
struct Reading {
limits: DataLimits,
bytes: usize,
entries: usize,
ancestors: Vec<ObjId>,
path: Vec<Step>,
function: Rc<str>,
}
impl Reading {
fn place(&self) -> String {
let mut s = String::new();
for step in &self.path {
match step {
Step::Index(i) => s.push_str(&format!("[{i}]")),
Step::Name(k) => {
s.push('.');
s.push_str(&String::from_utf16_lossy(k));
}
}
}
s
}
}
enum Shape {
Array(u32),
Plain(Vec<(Key, Prop)>),
}
impl Interp {
fn data_error(&mut self, r: &Reading, t: ErrorType, what: &str) -> Abrupt {
let message = format!("{}: argument{} {what}", r.function, r.place());
self.throw(t, &message)
}
fn charge_reading(&mut self, r: &mut Reading, bytes: usize) -> JsResult<()> {
r.bytes = r.bytes.saturating_add(bytes);
if r.bytes > r.limits.bytes {
return Err(self.data_error(r, ErrorType::Range, "is too large"));
}
Ok(())
}
fn charge_entries(&mut self, r: &mut Reading, n: usize) -> JsResult<()> {
r.entries = r.entries.saturating_add(n);
if r.entries > r.limits.entries {
return Err(self.data_error(r, ErrorType::Range, "has too many entries"));
}
Ok(())
}
fn read_data(&mut self, v: &Value, r: &mut Reading) -> JsResult<Data> {
match v {
Value::Undefined => Ok(Data::Undefined),
Value::Null => Ok(Data::Null),
Value::Bool(b) => Ok(Data::Bool(*b)),
Value::Number(n) if n.is_finite() => Ok(Data::Number(*n)),
Value::Number(_) => Err(self.data_error(r, ErrorType::Type, "is not a finite number")),
Value::String(s) => {
self.charge_reading(r, s.len().saturating_mul(3).saturating_add(2))?;
Ok(Data::String(s.to_rust_string()))
}
Value::Object(id) => self.read_object(*id, r),
}
}
fn read_object(&mut self, id: ObjId, r: &mut Reading) -> JsResult<Data> {
self.use_fuel()?;
if r.ancestors.contains(&id) {
return Err(self.data_error(r, ErrorType::Type, "refers to itself"));
}
if r.ancestors.len() >= r.limits.depth {
return Err(self.data_error(r, ErrorType::Range, "is nested too deep"));
}
let shape = match &self.object(id)?.class {
Class::Array { length, .. } => Shape::Array(*length),
Class::Ordinary => {
let o = self.object(id)?;
if o.props.len() > r.limits.entries {
return Err(self.data_error(r, ErrorType::Range, "has too many entries"));
}
Shape::Plain(o.props.iter().filter(|(_, p)| p.enumerable()).map(|(k, p)| (k.clone(), p.clone())).collect())
}
other => {
let what = format!("is a {}, not plain data", other.name());
return Err(self.data_error(r, ErrorType::Type, &what));
}
};
r.ancestors.push(id);
let out = match shape {
Shape::Array(length) => self.read_array(id, length, r),
Shape::Plain(props) => self.read_plain(props, r),
};
r.ancestors.pop();
out
}
fn read_array(&mut self, id: ObjId, length: u32, r: &mut Reading) -> JsResult<Data> {
self.charge_entries(r, length as usize)?;
let mut items = Vec::with_capacity(length as usize);
for i in 0..length {
self.use_fuel()?;
r.path.push(Step::Index(i));
let prop = self.object(id)?.props.get(&index_key(i)).cloned();
let item = match prop {
Some(Prop::Data { value: Value::Undefined, .. }) | None => Err(self.data_error(r, ErrorType::Type, "is undefined")),
Some(Prop::Data { value, .. }) => self.read_data(&value, r),
Some(Prop::Accessor { .. }) => Err(self.data_error(r, ErrorType::Type, "is a getter, not plain data")),
}?;
r.path.pop();
items.push(item);
}
Ok(Data::Array(items))
}
fn read_plain(&mut self, props: Vec<(Key, Prop)>, r: &mut Reading) -> JsResult<Data> {
self.charge_entries(r, props.len())?;
let mut out = Vec::with_capacity(props.len());
for (k, prop) in props {
self.use_fuel()?;
self.charge_reading(r, k.len().saturating_mul(3).saturating_add(8))?;
let name = String::from_utf16_lossy(&k);
r.path.push(Step::Name(k));
let item = match prop {
// A property holding `undefined` reads as one left out.
Prop::Data { value: Value::Undefined, .. } => None,
Prop::Data { value, .. } => Some(self.read_data(&value, r)?),
Prop::Accessor { .. } => return Err(self.data_error(r, ErrorType::Type, "is a getter, not plain data")),
};
r.path.pop();
if let Some(item) = item {
out.push((name, item));
}
}
Ok(Data::Object(out))
}
/// A host value as a JavaScript value, `frozen` when a script may not change it. A value
/// deeper or longer than the limits throws a `RangeError`.
pub(crate) fn write_data(&mut self, d: Data, depth: usize, frozen: bool) -> JsResult<Value> {
if depth > self.data_limits.depth {
return Err(self.throw(ErrorType::Range, "a host value is nested too deep"));
}
Ok(match d {
Data::Undefined => Value::Undefined,
Data::Null => Value::Null,
Data::Bool(b) => Value::Bool(b),
Data::Number(n) => Value::Number(n),
Data::String(s) => {
let bytes = string_bytes(s.encode_utf16().count());
self.reserve_heap(bytes)?;
self.heap.bytes = self.heap.bytes.saturating_add(bytes);
Value::str(&s)
}
Data::Array(items) => {
self.write_container(items.into_iter().enumerate().map(|(i, d)| (Slot::Index(i), d)).collect(), true, depth, frozen)?
}
Data::Object(items) => self.write_container(items.into_iter().map(|(k, d)| (Slot::Name(k), d)).collect(), false, depth, frozen)?,
})
}
fn write_container(&mut self, items: Vec<(Slot, Data)>, array: bool, depth: usize, frozen: bool) -> JsResult<Value> {
if items.len() > self.data_limits.entries {
return Err(self.throw(ErrorType::Range, "a host value has too many entries"));
}
let flags = if frozen { ENUMERABLE } else { crate::heap::OPEN };
self.reserve_heap(OBJECT_BYTES.saturating_add(PROP_BYTES.saturating_mul(items.len())))?;
let id = if array {
let proto = self.intrinsics.array_proto;
self.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: !frozen }, Some(proto)))
} else {
self.new_object()
};
let mark = self.temp_roots.len();
self.temp_roots.push(Value::Object(id));
let mut result = Ok(());
for (slot, d) in items {
let name = match slot {
Slot::Index(i) => index_key(u32::try_from(i).unwrap_or(u32::MAX)),
Slot::Name(n) => key(&n),
};
result = self.write_data(d, depth + 1, frozen).and_then(|value| self.define(id, name, Prop::Data { value, flags }));
if result.is_err() {
break;
}
}
self.temp_roots.truncate(mark);
result?;
if frozen {
self.object_mut(id)?.extensible = false;
}
Ok(Value::Object(id))
}
/// Calls host function `f`: reads its arguments, 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]) -> JsResult<Value> {
let mut reading = Reading { limits: self.data_limits, bytes: 0, entries: 0, ancestors: Vec::new(), path: Vec::new(), function: name.clone() };
let mut data = Vec::with_capacity(args.len());
for (i, a) in args.iter().enumerate() {
reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX)));
data.push(self.read_data(a, &mut reading)?);
reading.path.pop();
}
match f(data) {
Ok(d) => self.write_data(d, 0, false),
Err(HostError::Abort(message)) => {
self.host_abort = Some(message);
Err(Abrupt::Limit(LimitKind::Host))
}
Err(HostError::Error(message)) => Err(self.throw_host(ErrorType::Error, &message)?),
Err(HostError::Type(message)) => Err(self.throw_host(ErrorType::Type, &message)?),
Err(HostError::Range(message)) => Err(self.throw_host(ErrorType::Range, &message)?),
}
}
/// The exception for a host error, charged to the heap.
fn throw_host(&mut self, t: ErrorType, message: &str) -> JsResult<Abrupt> {
let bytes = string_bytes(message.encode_utf16().count());
self.reserve_heap(bytes)?;
self.heap.bytes = self.heap.bytes.saturating_add(bytes);
Ok(self.throw(t, message))
}
/// Adds the frozen object `name` with `items` to the global environment.
pub(crate) fn install_host_object(&mut self, name: &str, items: Vec<(String, HostItem)>) -> JsResult<()> {
let o = self.host_object(name, items)?;
let global = self.global;
self.define(global, key(name), Prop::Data { value: Value::Object(o), flags: 0 })
}
fn host_object(&mut self, path: &str, items: Vec<(String, HostItem)>) -> JsResult<ObjId> {
let o = self.new_object();
let mark = self.temp_roots.len();
self.temp_roots.push(Value::Object(o));
let mut result = Ok(());
for (name, item) in items {
result = self.install_item(o, path, name, item);
if result.is_err() {
break;
}
}
self.temp_roots.truncate(mark);
result?;
self.object_mut(o)?.extensible = false;
Ok(o)
}
fn install_item(&mut self, o: ObjId, path: &str, name: String, item: HostItem) -> JsResult<()> {
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)), flags: ENUMERABLE },
HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full)), set: None, flags: ENUMERABLE },
HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE },
};
self.define(o, key(&name), prop)
}
fn host_function(&mut self, f: HostFunction, name: &str) -> ObjId {
let proto = self.intrinsics.function_proto;
self.heap.alloc_object(Object::new(Class::Function(Func::Data { f, name: name.into() }), Some(proto)))
}
}
enum Slot {
Index(usize),
Name(String),
}
+288
View File
@@ -0,0 +1,288 @@
//! Resumable runs and the structured values host functions take and give.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use super::*;
fn realm(fuel: u64) -> Realm {
let mut r = Realm::new();
r.set_limits(Limits { fuel, ..Limits::default() });
r
}
/// A realm with `host.take(value)`, which records what it was given and returns it.
fn with_take(r: &mut Realm) -> Rc<RefCell<Vec<Data>>> {
let seen: Rc<RefCell<Vec<Data>>> = Rc::default();
let log = seen.clone();
let f: HostFunction = Rc::new(move |mut args| {
let v = if args.is_empty() { Data::Undefined } else { args.remove(0) };
log.borrow_mut().push(v.clone());
Ok(v)
});
r.define_host_object("host", vec![("take".into(), HostItem::Function(f))]).unwrap();
seen
}
fn finish(r: &mut Realm, slice: u64) -> (Result<JsValue, RunError>, u32) {
let mut slices = 0;
loop {
slices += 1;
match r.advance(slice) {
Ok(Progress::Paused) => {}
Ok(Progress::Done(v)) => return (Ok(v), slices),
Err(e) => return (Err(e), slices),
}
}
}
#[test]
fn a_resumed_run_gives_the_result_of_one_run() {
let source = "var t = 0; for (var i = 0; i < 2000; i++) { t += i; } t";
let whole = realm(10_000_000).run(source).unwrap();
let mut r = realm(10_000_000);
r.start(source).unwrap();
assert!(r.is_running());
let (v, slices) = finish(&mut r, 100);
assert_eq!(v.unwrap(), whole);
assert!(slices > 10, "the run paused: {slices} slices");
assert!(!r.is_running());
}
#[test]
fn state_survives_a_pause_and_a_collection_between_slices() {
let mut r = realm(10_000_000);
r.start("var o = {a: [1, 2, 3]}; var n = 0; for (var i = 0; i < 500; i++) { n += o.a.length; } n").unwrap();
assert_eq!(r.advance(50).unwrap(), Progress::Paused);
r.collect_garbage();
assert_eq!(finish(&mut r, 50).0.unwrap(), JsValue::Number(1500.0));
}
#[test]
fn the_fuel_limit_covers_every_slice_together() {
let mut r = realm(5_000);
r.start("while (true) {}").unwrap();
let (v, slices) = finish(&mut r, 100);
assert!(matches!(v, Err(RunError { kind: RunErrorKind::Limit(LimitKind::Fuel), .. })));
assert!((40..=60).contains(&slices), "{slices}");
}
#[test]
fn a_set_flag_stops_a_loop_in_a_getter() {
let mut r = realm(u64::MAX);
r.start("var o = {}; Object.defineProperty(o, 'x', {get: function () { while (true) {} }}); o.x").unwrap();
r.interrupt().set();
let e = r.advance(10).unwrap_err();
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Interrupted));
}
#[test]
fn a_nested_run_past_the_slice_allowance_is_the_unbroken_limit_and_a_run_with_little_fuel_is_the_fuel_limit() {
for (total, kind) in [(u64::MAX, LimitKind::Unbroken), (1_000, LimitKind::Fuel)] {
for source in [
"var o = {}; Object.defineProperty(o, 'x', {get: function () { while (true) {} }}); o.x",
"[1].forEach(function () { while (true) {} });",
] {
let mut r = realm(total);
r.set_unbroken_fuel(5_000);
r.start(source).unwrap();
let e = finish(&mut r, 1).0.unwrap_err();
assert_eq!(e.kind, RunErrorKind::Limit(kind), "{source}");
}
}
}
#[test]
fn neither_a_limit_nor_a_stop_can_be_caught() {
let mut r = realm(1_000);
r.start("try { while (true) {} } catch (e) { 'caught' } finally { 'ran' }").unwrap();
assert_eq!(finish(&mut r, 50).0.unwrap_err().kind, RunErrorKind::Limit(LimitKind::Fuel));
let shared = Interrupt::new();
let mut r = realm(1_000_000);
r.share_interrupt(&shared);
r.start("try { while (true) {} } catch (e) { 'caught' }").unwrap();
assert_eq!(r.advance(10).unwrap(), Progress::Paused);
shared.set();
assert_eq!(r.advance(10).unwrap_err().kind, RunErrorKind::Limit(LimitKind::Interrupted));
}
#[test]
fn a_host_function_can_end_the_slice() {
let mut r = realm(1_000_000);
let pause = r.pause_flag();
let f: HostFunction = Rc::new(move |_| {
pause.set(true);
Ok(Data::Null)
});
r.define_host_object("host", vec![("yield".into(), HostItem::Function(f))]).unwrap();
r.start("host.yield(); host.yield(); 7").unwrap();
assert_eq!(r.advance(1_000).unwrap(), Progress::Paused);
assert_eq!(r.advance(1_000).unwrap(), Progress::Paused);
assert_eq!(r.advance(1_000).unwrap(), Progress::Done(JsValue::Number(7.0)));
}
#[test]
fn plain_values_cross_in_both_directions() {
let mut r = realm(1_000_000);
let seen = with_take(&mut r);
let out = r.run("var v = host.take({a: 1, b: 'x', c: [true, null], d: undefined}); v.c[0] === true && v.b === 'x' && v.d === undefined");
assert_eq!(out.unwrap(), JsValue::Bool(true));
let want = Data::Object(vec![
("a".into(), Data::Number(1.0)),
("b".into(), Data::String("x".into())),
("c".into(), Data::Array(vec![Data::Bool(true), Data::Null])),
]);
assert_eq!(seen.borrow().as_slice(), [want]);
}
#[test]
fn a_callback_of_a_built_in_can_call_the_host() {
let mut r = realm(1_000_000);
let seen = with_take(&mut r);
r.start("[1, 2, 3].forEach(function (n) { host.take(n); });").unwrap();
finish(&mut r, 2).0.unwrap();
assert_eq!(seen.borrow().len(), 3);
}
/// `value` passed to the host throws an error of type `name` whose message holds `part`.
fn refused(setup: &str, name: &str, part: &str) {
let mut r = realm(10_000_000);
r.set_data_limits(DataLimits { depth: 8, bytes: 1_000, entries: 100 });
let seen = with_take(&mut r);
let probe = format!("var e; {setup} try {{ host.take(value); }} catch (x) {{ e = x; }} e ? e.name + '|' + e.message : 'none'");
let JsValue::String(out) = r.run(&probe).unwrap() else { panic!("{setup}") };
assert!(out.starts_with(name) && out.contains(part), "{setup}: {out}");
assert!(seen.borrow().is_empty(), "{setup}: the function ran");
}
#[test]
fn values_that_are_not_plain_data_are_refused_with_their_place() {
refused("var value = {}; value.self = value;", "TypeError", ".self refers to itself");
refused("var value = [[1]]; value[0][0] = value;", "TypeError", "[0][0][0] refers to itself");
refused("var value = {f: function () {}};", "TypeError", ".f is a Function");
refused("var value = {n: 1 / 0};", "TypeError", ".n is not a finite number");
refused("var value = {n: 0 / 0};", "TypeError", ".n is not a finite number");
refused("var value = {d: new Date(0)};", "TypeError", ".d is a Date");
refused("var value = {}; Object.defineProperty(value, 'g', {enumerable: true, get: function () { return 1; }});", "TypeError", ".g is a getter");
refused("var value = [1, , 3];", "TypeError", "[0][1] is undefined");
refused("var value = 1; for (var i = 0; i < 20; i++) value = [value];", "RangeError", "nested too deep");
refused("var value = new Array(5000);", "RangeError", "too many entries");
refused("var value = 'x'; for (var i = 0; i < 12; i++) value += value;", "RangeError", "too large");
}
#[test]
fn a_value_nested_ten_thousand_deep_is_refused_with_its_place() {
let mut r = realm(100_000_000);
let seen = with_take(&mut r);
let probe = "var v = 1; for (var i = 0; i < 10000; i++) v = {a: v}; var m = 'none'; \
try { host.take({args: v}); } catch (e) { m = e.name + '|' + e.message; } m";
let JsValue::String(out) = r.run(probe).unwrap() else { panic!("not a string") };
assert!(out.starts_with("RangeError|") && out.contains(".args.a.a.a") && out.contains("nested too deep"), "{out}");
assert!(seen.borrow().is_empty(), "the function ran");
}
#[test]
fn a_shared_object_is_allowed_and_sharing_cannot_multiply_the_work() {
let mut r = realm(10_000_000);
r.set_data_limits(DataLimits { depth: 64, bytes: 16 << 20, entries: 1_000 });
let seen = with_take(&mut r);
r.run("var s = {a: 1}; host.take({x: s, y: s, z: [s, s]});").unwrap();
assert_eq!(seen.borrow().len(), 1);
// Each level doubles the paths to the bottom: the entry limit ends the walk.
let JsValue::String(out) =
r.run("var v = [1]; for (var i = 0; i < 40; i++) v = [v, v]; var m = 'none'; try { host.take(v); } catch (e) { m = e.name; } m").unwrap()
else {
panic!()
};
assert_eq!(out, "RangeError");
}
#[test]
fn a_host_object_cannot_be_changed_by_a_script() {
let mut r = realm(1_000_000);
let args = Data::Object(vec![("list".into(), Data::Array(vec![Data::Number(1.0)]))]);
r.define_host_object("vc", vec![("args".into(), HostItem::Value(args))]).unwrap();
let out = r.run(
"vc.args.list[0] = 2; vc.args.list.push; vc.args.more = 1; vc.extra = 1; var was = vc; vc = 3; \
[was.args.list[0], was.args.more === undefined, was.extra === undefined, Object.isFrozen(was.args.list)].join()",
);
assert_eq!(out.unwrap(), JsValue::String("1,true,true,true".into()));
}
#[test]
fn a_getter_is_read_each_time() {
let mut r = realm(1_000_000);
let count = Rc::new(Cell::new(0.0));
let c = count.clone();
let f: HostFunction = Rc::new(move |_| {
c.set(c.get() + 1.0);
Ok(Data::Number(c.get()))
});
r.define_host_object("host", vec![("n".into(), HostItem::Getter(f))]).unwrap();
assert_eq!(r.run("host.n + host.n").unwrap(), JsValue::Number(3.0));
}
#[test]
fn errors_from_the_host_can_be_caught_and_an_abort_cannot() {
let mut r = realm(1_000_000);
let f: HostFunction = Rc::new(|a| match a.first() {
Some(Data::String(k)) if k == "type" => Err(HostError::Type("bad type".into())),
Some(Data::String(k)) if k == "range" => Err(HostError::Range("bad range".into())),
Some(Data::String(k)) if k == "abort" => Err(HostError::Abort("stopped by the host".into())),
_ => Err(HostError::Error("bad".into())),
});
r.define_host_object("host", vec![("f".into(), HostItem::Function(f))]).unwrap();
let out = r.run(
"var a = []; ['type', 'range', 'x'].forEach(function (k) { try { host.f(k); } catch (e) { a.push(e.name + ':' + e.message); } }); a.join()",
);
assert_eq!(out.unwrap(), JsValue::String("TypeError:bad type,RangeError:bad range,Error:bad".into()));
let e = r.run("try { host.f('abort'); } catch (e) { 'caught' }").unwrap_err();
assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Limit(LimitKind::Host), "stopped by the host"));
}
#[test]
fn an_error_says_where_in_the_source_it_was_raised() {
let mut r = realm(1_000_000);
r.start("var a = 1;\n throw new TypeError('no');").unwrap();
let e = finish(&mut r, 1).0.unwrap_err();
assert_eq!((e.kind, e.line), (RunErrorKind::Thrown, 2));
assert!(e.column >= 3, "{}", e.column);
assert_eq!(e.message, "TypeError: no");
}
#[test]
fn advancing_without_a_run_is_an_error() {
let mut r = realm(1_000);
let e = r.advance(10).unwrap_err();
assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Internal, "no run to advance"));
}
proptest::proptest! {
/// A slice size does not change what a script computes.
#[test]
fn any_slice_size_gives_the_same_result(slice in 1u64..500, n in 0u32..300) {
let source = format!("var a = []; for (var i = 0; i < {n}; i++) a.push(i * 2); a.join().length");
let whole = realm(50_000_000).run(&source).unwrap();
let mut r = realm(50_000_000);
r.start(&source).unwrap();
proptest::prop_assert_eq!(finish(&mut r, slice).0.unwrap(), whole);
}
/// Hostile text for a value never makes the bridge panic.
#[test]
fn hostile_values_end_with_a_value_or_a_script_error(depth in 0usize..120, width in 0usize..6, cycle in proptest::bool::ANY) {
let mut r = realm(5_000_000);
with_take(&mut r);
let mut s = String::from("var v = [];");
for _ in 0..depth {
s.push_str(&format!("v = [v{}];", ",v".repeat(width.min(2))));
}
if cycle {
s.push_str("var c = {}; c.c = c; v = [v, c];");
}
s.push_str("var ok = 'ok'; try { host.take(v); } catch (e) { ok = e.name; } ok");
let JsValue::String(out) = r.run(&s).unwrap() else { panic!() };
proptest::prop_assert!(matches!(out.as_str(), "ok" | "RangeError" | "TypeError"), "{}", out);
}
}
+48 -7
View File
@@ -179,6 +179,12 @@ pub(crate) struct Interp {
pub(crate) date_host: crate::date_builtins::DateHost,
/// The bytes native code holds outside the heap's cells through [`HeldBytes`].
pub(crate) held: Rc<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 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.
pub(crate) data_limits: crate::DataLimits,
}
/// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's
@@ -367,6 +373,9 @@ impl Interp {
install_error: None,
date_host: crate::date_builtins::DateHost::default(),
held: Rc::default(),
pause: Rc::default(),
host_abort: None,
data_limits: crate::DataLimits::default(),
};
crate::builtins::install(&mut interp);
interp.fuel = limits.fuel;
@@ -558,6 +567,28 @@ impl Interp {
}
}
/// Runs the boundary frame on top until it returns (`Some` with its value) or the remaining
/// fuel falls to `pause_at`, or the host asks for a pause (`None`, with the frames left in
/// place for the next call). Only this outermost loop pauses: a nested run has native frames
/// under it and goes on until it returns.
pub(crate) fn execute_slice(&mut self, pause_at: u64) -> JsResult<Option<Value>> {
loop {
match self.step() {
Ok(Some(v)) => return Ok(Some(v)),
Ok(None) => {}
Err(Abrupt::Throw(v)) => self.unwind(v)?,
Err(other) => {
self.note_site();
self.abort_to_boundary();
return Err(other);
}
}
if self.fuel <= pause_at || self.pause.replace(false) {
return Ok(None);
}
}
}
fn step(&mut self) -> JsResult<Option<Value>> {
self.tick()?;
let frame = self.frame_mut()?;
@@ -966,11 +997,11 @@ impl Interp {
// ---- properties ----
fn object(&self, id: ObjId) -> JsResult<&Object> {
pub(crate) fn object(&self, id: ObjId) -> JsResult<&Object> {
self.heap.object(id).ok_or_else(|| Abrupt::Internal("object out of range".into()))
}
fn object_mut(&mut self, id: ObjId) -> JsResult<&mut Object> {
pub(crate) fn object_mut(&mut self, id: ObjId) -> JsResult<&mut Object> {
self.heap.object_mut(id).ok_or_else(|| Abrupt::Internal("object out of range".into()))
}
@@ -1815,6 +1846,11 @@ impl Interp {
self.temp_roots.truncate(mark);
return Ok(Prepared::Done(r?));
}
Func::Data { f, name } => {
// 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)?));
}
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) {
@@ -2135,6 +2171,15 @@ impl Interp {
if is_eval && self.reentry >= self.limits.reentry {
return Err(Abrupt::Limit(LimitKind::Reentry));
}
self.enter_global(code, is_eval)?;
self.reentry += nested;
let r = self.execute();
self.reentry -= nested;
r
}
/// Declares `code`'s variables and functions and pushes its boundary frame, ready to run.
pub(crate) fn enter_global(&mut self, code: Rc<Code>, is_eval: bool) -> JsResult<()> {
self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?;
let (env, global) = (self.global_env, self.global);
let (env, var_env) = if code.strict && is_eval {
@@ -2144,11 +2189,7 @@ impl Interp {
(env, env)
};
self.instantiate_global(&code, var_env, is_eval)?;
self.push_code_frame(code, env, var_env, Value::Object(global), true)?;
self.reentry += nested;
let r = self.execute();
self.reentry -= nested;
r
self.push_code_frame(code, env, var_env, Value::Object(global), true)
}
fn push_code_frame(&mut self, code: Rc<Code>, env: EnvId, var_env: EnvId, this: Value, boundary: bool) -> JsResult<()> {
+5 -1
View File
@@ -47,6 +47,7 @@ mod compiler;
mod date_builtins;
mod error;
mod heap;
mod host;
mod interp;
mod lexer;
mod number;
@@ -65,9 +66,10 @@ 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 lexer::{Goal, Lexer};
pub use number::to_string as number_to_string;
pub use realm::{Interrupt, JsValue, LimitKind, Limits, Realm, RunError, RunErrorKind};
pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind};
pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind};
/// How a script is read.
@@ -185,6 +187,8 @@ impl LineTable {
#[cfg(test)]
mod builtins_tests;
#[cfg(test)]
mod host_tests;
#[cfg(test)]
mod interp_tests;
#[cfg(test)]
mod tests;
+122 -8
View File
@@ -51,14 +51,36 @@ pub enum LimitKind {
Reentry,
/// The host set the interrupt flag.
Interrupted,
/// A getter, a callback of a built-in or another nested run spent [`MAX_UNBROKEN_FUEL`]
/// without returning to the point where a resumable run can pause.
Unbroken,
/// A host function ended the run ([`crate::HostError::Abort`]).
Host,
}
/// The fuel [`Realm::advance`] lets a slice spend, past the slice it was asked for, in a nested run
/// it cannot pause.
pub const MAX_UNBROKEN_FUEL: u64 = 200_000_000;
/// How far a resumable run got.
#[derive(Clone, Debug, PartialEq)]
pub enum Progress {
/// The slice ended; call [`Realm::advance`] again.
Paused,
/// The script ended, with its completion value.
Done(JsValue),
}
/// A flag the host sets, from any thread, to stop the running script at its next instruction.
/// It stays set until [`Interrupt::clear`].
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Default)]
pub struct Interrupt(Arc<AtomicBool>);
impl Interrupt {
pub fn new() -> Self {
Self::default()
}
pub fn set(&self) {
self.0.store(true, Ordering::Relaxed);
}
@@ -121,6 +143,7 @@ impl From<SyntaxError> for RunError {
/// A global environment with the built-ins, in which scripts run one after another.
pub struct Realm {
interp: Interp,
unbroken_fuel: u64,
}
impl Default for Realm {
@@ -138,7 +161,13 @@ impl Realm {
/// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it
/// counted together.
pub fn with_options(options: Options, limits: Limits) -> Self {
Self { interp: Interp::new(options, limits) }
Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL }
}
/// Sets the fuel [`Realm::advance`] lets a nested run spend past its slice (by default
/// [`MAX_UNBROKEN_FUEL`]).
pub fn set_unbroken_fuel(&mut self, fuel: u64) {
self.unbroken_fuel = fuel;
}
pub fn limits(&self) -> &Limits {
@@ -173,6 +202,13 @@ impl Realm {
/// value of the last expression statement that ran). A realm whose built-ins could not be
/// installed runs nothing and gives an internal error.
pub fn run(&mut self, source: &str) -> Result<JsValue, RunError> {
let code = self.compile(source)?;
let result = self.interp.run_global(code, false);
self.outcome(result)
}
/// Compiles `source` and readies the realm for a run.
fn compile(&mut self, source: &str) -> Result<std::rc::Rc<crate::bytecode::Code>, RunError> {
let interp = &mut self.interp;
if let Some(message) = &interp.install_error {
return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 });
@@ -181,13 +217,18 @@ impl Realm {
interp.source_units = 0;
interp.throw_site = None;
interp.last_thrown = Value::Undefined;
interp.host_abort = None;
let strict = interp.options.strict;
let code = match interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)) {
Ok(code) => code,
Err(CompileError::Syntax(e)) => return Err(e.into()),
Err(CompileError::Internal(m)) => return Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }),
};
let result = interp.run_global(code, false);
match interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)) {
Ok(code) => Ok(code),
Err(CompileError::Syntax(e)) => Err(e.into()),
Err(CompileError::Internal(m)) => Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }),
}
}
/// The end of a run: its completion value or error, with the interpreter's stacks emptied.
fn outcome(&mut self, result: Result<Value, Abrupt>) -> Result<JsValue, RunError> {
let interp = &mut self.interp;
let (line, column) = interp.throw_site.take().map_or((0, 0), |(code, pc)| code.line_column(pc));
interp.stack.clear();
interp.frames.clear();
@@ -200,11 +241,84 @@ impl Realm {
interp.last_thrown = v;
Err(RunError { kind: RunErrorKind::Thrown, message, line, column })
}
Err(Abrupt::Limit(LimitKind::Host)) => {
let message = interp.host_abort.take().unwrap_or_else(|| format!("limit reached: {:?}", LimitKind::Host));
Err(RunError { kind: RunErrorKind::Limit(LimitKind::Host), message, line, column })
}
Err(Abrupt::Limit(kind)) => Err(RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line, column }),
Err(Abrupt::Internal(message)) => Err(RunError { kind: RunErrorKind::Internal, message, line, column }),
}
}
/// Begins a run that [`Realm::advance`] carries on: compiles `source` and enters it.
pub fn start(&mut self, source: &str) -> Result<(), RunError> {
let code = self.compile(source)?;
match self.interp.enter_global(code, false) {
Ok(()) => Ok(()),
Err(e) => self.outcome(Err(e)).map(drop),
}
}
/// Runs the script begun with [`Realm::start`] for about `slice` units of fuel (at least one
/// 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.
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 });
}
let total = interp.fuel;
let cap = slice.saturating_add(self.unbroken_fuel).min(total);
interp.fuel = cap;
let r = interp.execute_slice(cap - slice.min(cap));
let used = cap - interp.fuel;
interp.fuel = total - used;
match r {
Ok(None) => Ok(Progress::Paused),
Ok(Some(v)) => self.outcome(Ok(v)).map(Progress::Done),
// The run's own fuel is not used up, so it was the allowance of this slice.
Err(Abrupt::Limit(LimitKind::Fuel)) if total > cap => self.outcome(Err(Abrupt::Limit(LimitKind::Unbroken))).map(Progress::Done),
Err(e) => self.outcome(Err(e)).map(Progress::Done),
}
}
/// Is a run begun with [`Realm::start`] still going?
pub fn is_running(&self) -> bool {
!self.interp.frames.is_empty()
}
/// The flag a host function sets to end the current slice at the next point it can pause.
pub fn pause_flag(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
self.interp.pause.clone()
}
/// 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();
}
/// Fuel the run has left.
pub fn fuel_left(&self) -> u64 {
self.interp.fuel
}
/// Sets how much a value passed to or from a host function may carry.
pub fn set_data_limits(&mut self, limits: crate::DataLimits) {
self.interp.data_limits = limits;
}
/// Adds the frozen object `name`, with `items`, to the global environment. Scripts cannot add
/// to it, change it or replace it. A heap too small for it ends with the error the realm's
/// next run would give.
pub fn define_host_object(&mut self, name: &str, items: Vec<(String, crate::HostItem)>) -> Result<(), RunError> {
match self.interp.install_host_object(name, items) {
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) {
+75 -1
View File
@@ -18,6 +18,20 @@ pub const DOC_JSON_URI: &str = "vectorcraft://document/json";
/// The live command catalog, including enabled state.
pub const COMMANDS_URI: &str = "vectorcraft://commands";
/// The command API reference in Markdown (`engine.apiReference`).
pub const REFERENCE_URI: &str = "vectorcraft://commands/reference";
/// The media type of the JSON resources.
pub const JSON: &str = "application/json";
/// The media type of the command API reference.
pub const MARKDOWN: &str = "text/markdown; charset=utf-8";
/// What `resources/read` returns for a URI: the text and its media type.
#[derive(Debug, PartialEq)]
pub struct Contents {
pub mime: &'static str,
pub text: String,
}
/// Why a resource could not be read. The distinction matters: the server turns it into `-32002`
/// (no such resource) or `-32602` (bad URI), and guessing from the message text would not hold.
@@ -83,9 +97,24 @@ pub fn list() -> Value {
{"uri": DOC_URI, "name": "document", "title": "Active document (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"},
{"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "mimeType": "application/json"},
{"uri": COMMANDS_URI, "name": "commands", "title": "Command catalog", "description": "Command ids, labels, parameter descriptions and current enabled state", "mimeType": "application/json"},
{"uri": REFERENCE_URI, "name": "command-reference", "title": "Command API reference", "description": "Markdown reference of the commands that have a parameter schema: types, ranges, units, defaults, API levels, what each writes and returns (engine.apiReference)", "mimeType": MARKDOWN},
]})
}
/// `resources/read`: the text behind a resource URI, the command API reference as Markdown and
/// every other resource as indented JSON ([`read`]).
pub fn read_contents(b: &mut dyn Backend, uri: &str) -> Result<Contents, ReadError> {
if uri == REFERENCE_URI {
let mut reply = exec(b, "engine.apiReference").map_err(ReadError::Backend)?;
return match reply.get_mut("markdown").map(Value::take) {
Some(Value::String(text)) => Ok(Contents { mime: MARKDOWN, text }),
_ => Err(ReadError::Backend("engine.apiReference returned no `markdown` text".into())),
};
}
let v = read(b, uri)?;
Ok(Contents { mime: JSON, text: serde_json::to_string_pretty(&v).unwrap_or_default() })
}
/// `resources/templates/list`.
pub fn templates() -> Value {
let list: Vec<Value> = TEMPLATES
@@ -246,7 +275,7 @@ mod tests {
#[test]
fn lists_are_well_formed() {
let l = list();
assert_eq!(l["resources"].as_array().map(Vec::len), Some(3));
assert_eq!(l["resources"].as_array().map(Vec::len), Some(4));
let t = templates();
let items = t["resourceTemplates"].as_array().expect("templates");
assert_eq!(items.len(), TEMPLATES.len());
@@ -257,6 +286,51 @@ mod tests {
}
}
/// A backend that gives every call the same reply.
struct Fixed(Result<Value, String>);
impl Backend for Fixed {
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
self.0.clone()
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"fixed".into()
}
}
/// The command reference is listed and read as Markdown, with the text `engine.apiReference`
/// returns, and with no document open; the other fixed resources still read as JSON.
#[test]
fn the_command_reference_reads_as_markdown() {
let listed = list();
let entry = listed["resources"].as_array().and_then(|r| r.iter().find(|r| r["uri"] == REFERENCE_URI)).expect("listed");
assert_eq!(entry["mimeType"], MARKDOWN);
assert_eq!(entry["name"], "command-reference");
let mut b = Box::new(crate::Headless::new());
let read = read_contents(b.as_mut(), REFERENCE_URI).unwrap();
assert_eq!(read.mime, MARKDOWN);
let direct = b.call("engine.execute", json!({"command": "engine.apiReference", "params": {}})).unwrap();
assert_eq!(read.text, direct["markdown"].as_str().unwrap());
assert!(read.text.starts_with("# VectorCraft command API reference\n"), "{}", read.text);
assert_eq!(read_contents(b.as_mut(), REFERENCE_URI).unwrap(), read);
let commands = read_contents(b.as_mut(), COMMANDS_URI).unwrap();
assert_eq!(commands.mime, JSON);
assert!(serde_json::from_str::<Value>(&commands.text).unwrap().is_array());
assert!(matches!(read_contents(b.as_mut(), "vectorcraft://commands/nope"), Err(ReadError::NotFound(_))));
}
/// A reply without the Markdown text, and an app that cannot be reached, are backend errors.
#[test]
fn a_bad_or_failed_reference_reply_is_a_backend_error() {
for reply in [Ok(json!({})), Ok(json!({"markdown": 5})), Ok(json!("text")), Err("the app is not running".to_string())] {
let mut b = Fixed(reply.clone());
assert!(matches!(read_contents(&mut b, REFERENCE_URI), Err(ReadError::Backend(_))), "{reply:?}");
}
}
#[test]
fn missing_and_unknown_uris_are_errors() {
let mut b = Box::new(crate::Headless::with_document());
+9 -4
View File
@@ -28,7 +28,8 @@ const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
/// The engine is running a long call and this request was not run (implementation-defined range).
/// The engine is running a long call or a script and this request was not run
/// (implementation-defined range).
pub(crate) const SERVER_BUSY: i64 = -32003;
/// How long a call runs before a request that would have to wait behind it is refused as busy
@@ -435,16 +436,20 @@ impl Server {
if let Some(arguments) = args.as_object() {
crate::tools::check_tool_arguments(name, arguments).map_err(|e| (INVALID_PARAMS, e))?;
}
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
let result = call_tool(self.backend.as_mut(), name, &args);
match result.script_busy() {
Some(message) => Err((SERVER_BUSY, message)),
None => Ok(result.to_value()),
}
}
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e {
let read = resources::read_contents(self.backend.as_mut(), uri).map_err(|e| match e {
resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")),
resources::ReadError::Invalid(why) => (INVALID_PARAMS, why),
resources::ReadError::Backend(why) => (INTERNAL_ERROR, why),
})?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
Ok(json!({"contents": [{"uri": uri, "mimeType": read.mime, "text": read.text}]}))
}
"completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)),
"logging/setLevel" => {
+1 -1
View File
@@ -304,7 +304,7 @@ fn headless_end_to_end() {
// Resources.
let v = rpc(&mut s, 10, "resources/list", json!({}));
assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 3);
assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 4);
let v = rpc(&mut s, 11, "resources/read", json!({"uri": "vectorcraft://document"}));
let text = v["result"]["contents"][0]["text"].as_str().unwrap();
assert!(serde_json::from_str::<Value>(text).unwrap()["layers"].is_array());
+17
View File
@@ -344,6 +344,23 @@ fn a_cancelled_export_through_the_real_engine_writes_nothing_and_leaves_the_sess
thread.join().unwrap();
}
#[test]
fn a_change_while_a_script_runs_is_refused_as_busy() {
let mut backend = crate::Headless::with_document();
backend.session.script_frames = true;
backend.session.execute("script.run", &json!({"source": "while (true) {}"})).unwrap();
let mut s = Server::new(Box::new(backend));
let call = |id: &str, params: Value| json!({"name": "command_run", "arguments": {"id": id, "params": params}});
let reply = super::rpc(&mut s, 1, "tools/call", call("shape.rectangle", json!({"x": 0, "y": 0, "width": 1, "height": 1})));
assert_eq!(reply["error"]["code"], -32003, "{reply}");
assert!(reply["error"]["message"].as_str().unwrap().contains("a script is running"), "{reply}");
let reply = super::rpc(&mut s, 2, "tools/call", json!({"name": "doc_inspect", "arguments": {}}));
assert_eq!(reply["result"]["isError"], false, "reading the document is not refused: {reply}");
let reply = super::rpc(&mut s, 3, "tools/call", call("no.such", json!({})));
assert_eq!(reply["result"]["isError"], true, "any other failure stays a tool error: {reply}");
assert!(reply.get("error").is_none(), "{reply}");
}
#[test]
fn a_remote_call_stops_waiting_for_the_app_when_cancelled() {
use std::io::BufRead;
+5
View File
@@ -36,4 +36,9 @@ impl ToolResult {
pub fn to_value(&self) -> Value {
json!({"content": self.content, "isError": self.is_error})
}
/// The error's text, when the engine refused the call because a script is running.
pub(crate) fn script_busy(&self) -> Option<String> {
let text = self.content.first()?.get("text")?.as_str()?;
(self.is_error && text.ends_with(vectorcraft_engine::SCRIPT_BUSY)).then(|| text.to_string())
}
}
+10 -2
View File
@@ -290,8 +290,16 @@ fn resources_are_json() {
let list = rpc(&mut s, 2, "resources/list", json!({}));
for r in list["result"]["resources"].as_array().unwrap() {
let v = rpc(&mut s, 3, "resources/read", json!({"uri": r["uri"]}));
let text = v["result"]["contents"][0]["text"].as_str().unwrap();
serde_json::from_str::<Value>(text).unwrap();
let content = &v["result"]["contents"][0];
assert_eq!(content["mimeType"], r["mimeType"], "{}", r["uri"]);
assert_eq!(content["uri"], r["uri"]);
let text = content["text"].as_str().unwrap();
if r["mimeType"] == "application/json" {
serde_json::from_str::<Value>(text).unwrap();
} else {
assert_eq!(r["mimeType"], "text/markdown; charset=utf-8", "{}", r["uri"]);
assert!(text.starts_with("# ") && text.ends_with('\n'), "{}", r["uri"]);
}
}
}
+10 -3
View File
@@ -98,7 +98,9 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
brushes: None,
knockout: doc.page_knockout,
anonymous: false,
knockout_filter: None,
knockout_filters: HashMap::new(),
region_boxes: None,
art_reach: HashMap::new(),
warnings: vec![],
id_prefix,
linked: Vec::new(),
@@ -255,8 +257,13 @@ struct Writer<'a> {
knockout: bool,
/// Writing a copy of an object (a knockout mask): no object ids, so they stay unique.
anonymous: bool,
/// The filter that paints art black keeping its alpha (knockout masks), once defined.
knockout_filter: Option<String>,
/// The filters that paint art black keeping its alpha (knockout masks), by the region
/// attributes they were defined with.
knockout_filters: HashMap<String, String>,
/// The document's symbol boxes, measured once for every mask and filter region.
region_boxes: Option<vectorcraft_doc::SymbolBoxes<'a>>,
/// How far each symbol's art paints past its box, by name, once measured for a region.
art_reach: HashMap<String, f64>,
/// The brush library, parsed when the first brushed stroke is written.
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// Features written approximately (once each).
+148 -16
View File
@@ -2,8 +2,8 @@ use super::output::{sanitize_id, valid_xml_value, xml_attr_escape, xml_attr_vali
use super::paint::{StrokePlan, has_raster, pixel_gradient};
use super::text::OUTLINE_TOLERANCE;
use super::{
Affine, AppearanceItem, BezPath, BlendMode, Document, FillRule, Node, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, StrokeLayer, Writer,
Written, WrittenShape, blend_css, css, fmt_num, stroke, xml_escape,
Affine, AppearanceItem, BezPath, BlendMode, Document, FillRule, HashMap, Node, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, StrokeLayer,
Writer, Written, WrittenShape, blend_css, css, fmt_num, stroke, xml_escape,
};
/// A symbol's `<symbol>` def and the instance transforms that can `<use>` it.
@@ -143,9 +143,12 @@ fn pin_to_page(n: &mut Node, m: Affine) {
const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups";
pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
/// A region covering any artwork (mask and filter extents).
/// The region of a mask or filter over art that has no box to measure: one covering any artwork.
const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\"";
/// How deep [`Writer::paint_reach`] looks into members and symbol art.
const MAX_REACH_DEPTH: u32 = 64;
/// Attributes of every `<mask>`: user-space units, and luminance taken from the sRGB values (as the
/// canvas and PDF take it) rather than from linearised ones.
pub(super) const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\"";
@@ -354,13 +357,18 @@ impl Writer<'_> {
return self.node_body(&bare);
}
let mid = self.fresh_id("mask");
let mut bare = n.clone();
bare.extras_mut().mask = None;
// The region covers what the object and the mask art paint: readers draw the mask into an
// image of the region's size.
let region = self.region_attrs(&[&bare, &m.art]);
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
let knockout = std::mem::take(&mut self.knockout);
let art = self.detached(2, |w| w.node(&m.art));
self.knockout = knockout;
let inv = m.invert.then(|| {
let fid = self.fresh_id("invert");
self.def(1, &format!("<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\" {BIG}>"));
self.def(1, &format!("<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\" {region}>"));
self.def(2, "<feColorMatrix type=\"matrix\" values=\"-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0\"/>");
self.def(1, "</filter>");
fid
@@ -368,13 +376,13 @@ impl Writer<'_> {
// The mask's options, so import restores them (and its art without backdrop and filter).
let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect();
let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) };
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}{data}>"));
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {region}{data}>"));
if let Some(fid) = &inv {
self.def(1, &format!("<g filter=\"url(#{fid})\">"));
}
if !m.clip || m.invert {
let c = if m.clip { "black" } else { "white" };
self.def(1, &format!("<rect {BIG} fill=\"{c}\"/>"));
self.def(1, &format!("<rect {region} fill=\"{c}\"/>"));
}
self.defs.push_str(&art);
if inv.is_some() {
@@ -383,8 +391,6 @@ impl Writer<'_> {
self.def(1, "</mask>");
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.depth += 1;
let mut bare = n.clone();
bare.extras_mut().mask = None;
self.node_body(&bare);
self.depth -= 1;
self.line("</g>");
@@ -399,6 +405,127 @@ impl Writer<'_> {
std::mem::replace(&mut self.body, body)
}
/// The `x`, `y`, `width` and `height` of a mask or filter region over `nodes`, in the current
/// user space: the box of everything they paint ([`Self::paint_reach`]), 2 larger on each
/// side for antialiasing. [`BIG`] when none of them has a box, when one holds type (a reader
/// sets live type in its own font, which can be wider, and type on a path is measured by its
/// path), or when one nests deeper than [`MAX_REACH_DEPTH`]. The art of a symbol instance
/// written in place keeps its strokes and effects at their own size while the instance scales
/// its geometry, so the region also covers the geometry's box, placed, grown by the strokes'
/// and effects' reach unscaled.
fn region_attrs(&mut self, nodes: &[&Node]) -> String {
let symbols = self.region_boxes.take().unwrap_or_else(|| self.doc.symbol_boxes());
let mut art_reach = std::mem::take(&mut self.art_reach);
let region = self.region_of(&symbols, &mut art_reach, nodes);
self.region_boxes = Some(symbols);
self.art_reach = art_reach;
region
}
/// [`Self::region_attrs`] with the document's symbol boxes and the symbols' art reach so far.
fn region_of(&self, symbols: &vectorcraft_doc::SymbolBoxes, art_reach: &mut HashMap<String, f64>, nodes: &[&Node]) -> String {
let mut painted: Option<Rect> = None;
for n in nodes {
let Some(v) = n.visual_bounds_with(symbols) else { continue };
let Some(r) = self.paint_reach(symbols, art_reach, n, v, 0) else {
return BIG.to_string();
};
let g = n.geometric_bounds_with(symbols).unwrap_or(v);
let stroke = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1].into_iter().fold(0.0, f64::max);
let scaled = self.xf.transform_rect_bbox(v.inflate(r, r));
let unscaled = self.xf.transform_rect_bbox(g).inflate(stroke + r, stroke + r);
let both = scaled.union(unscaled);
painted = Some(painted.map_or(both, |p| p.union(both)));
}
let Some(b) = painted.map(|b| b.inflate(2.0, 2.0)).filter(|b| b.is_finite()) else {
return BIG.to_string();
};
format!("x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()))
}
/// How far what `n` paints reaches past its visual box `b`: the reach of its own raster effects
/// and of those of its fills and strokes (measured as [`Self::open_filters`] measures them),
/// how far brush art can paint past its brushed strokes (`16 × w + 8` for the widest brushed
/// stroke `w`, as the canvas allows for it), and the most that one of its members or its
/// symbol's art reaches past its own box, spread by its own effects. `art_reach` keeps each
/// symbol's art reach by name once measured, so art used many times is measured once. A live
/// object is measured as the art it is written as: expanded on its own, as [`Self::node`]
/// expands it, so with the same limits. `None` when `n`, its members or its symbol's art hold
/// type, or nest deeper than [`MAX_REACH_DEPTH`].
fn paint_reach(
&self,
symbols: &vectorcraft_doc::SymbolBoxes,
art_reach: &mut HashMap<String, f64>,
n: &Node,
b: Rect,
depth: u32,
) -> Option<f64> {
use vectorcraft_effects::RasterFx;
// Type draws past its measured box in a reader's own font.
if matches!(n.kind, NodeKind::Text(_)) {
return None;
}
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
let grow = 18.0 / self.doc.raster_ppi();
let reach = |effects: &[vectorcraft_doc::Effect]| -> f64 {
vectorcraft_effects::raster_effects(effects)
.iter()
.map(|f| f.outset(b) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 })
.sum()
};
let items = n.appearance.items.iter().map(|i| reach(i.effects())).fold(0.0, f64::max);
let brush = if vectorcraft_brush::has_brush(n) {
let w =
n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max);
16.0 * w + 8.0
} else {
0.0
};
let own = reach(&n.appearance.effects) + items + brush;
if vectorcraft_doc::live::is_live(n) {
if depth >= MAX_REACH_DEPTH {
return None;
}
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
let Some(gb) = g.visual_bounds_with(symbols) else { return Some(own) };
let past = [b.x0 - gb.x0, b.y0 - gb.y0, gb.x1 - b.x1, gb.y1 - b.y1].into_iter().fold(0.0, f64::max);
return Some(own + past + self.paint_reach(symbols, art_reach, &g, gb, depth + 1)?);
}
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => self.doc.symbols.iter().find(|s| s.name == *symbol).map(|s| (s, xf)),
_ => None,
};
if n.children().is_none_or(|c| c.is_empty()) && symbol.is_none() {
return Some(own);
}
if depth >= MAX_REACH_DEPTH {
return None;
}
let mut inner: f64 = 0.0;
for c in n.children().into_iter().flatten() {
if let Some(cb) = c.visual_bounds_with(symbols) {
inner = inner.max(self.paint_reach(symbols, art_reach, c, cb, depth + 1)?);
}
}
if let Some((s, xf)) = symbol {
let art = match art_reach.get(&s.name) {
Some(r) => *r,
None => {
let r = match s.art.visual_bounds_with(symbols) {
Some(ab) => self.paint_reach(symbols, art_reach, &s.art, ab, depth + 1)?,
None => 0.0,
};
art_reach.insert(s.name.clone(), r);
r
}
};
// The art's reach, measured in symbol space, as far as the instance can stretch it.
let [xx, xy, yx, yy, _, _] = xf.as_coeffs();
inner = inner.max(art * xx.hypot(xy).max(yx.hypot(yy)).max(1.0));
}
Some(own + inner)
}
/// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed.
fn group_props(&self, n: &Node) -> Props {
let mut p = css::transparency(n);
@@ -450,9 +577,12 @@ impl Writer<'_> {
written.push((before, self.detached(depth + nest - 1 - i, |w| w.node(c))));
}
let after = self.symbol_nodes;
// Each mask covers what the group's elements paint.
let all: Vec<&Node> = elements.iter().map(|c| &***c).collect();
let region = self.region_attrs(&all);
for (c, (before, _)) in rest.iter().zip(&written).rev() {
self.symbol_nodes = *before;
let mid = self.knockout_mask(c);
let mid = self.knockout_mask(c, &region);
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.depth += 1;
}
@@ -466,16 +596,18 @@ impl Writer<'_> {
}
/// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object
/// opacity without its own mask, unless those define its shape). Returns the mask id.
fn knockout_mask(&mut self, c: &Node) -> String {
let filter = match &self.knockout_filter {
/// opacity without its own mask, unless those define its shape). `region` is the attributes of
/// the mask's region ([`Self::region_attrs`]), which the filter that paints `c` black shares.
/// Returns the mask id.
fn knockout_mask(&mut self, c: &Node, region: &str) -> String {
let filter = match self.knockout_filters.get(region) {
Some(f) => f.clone(),
None => {
let f = self.fresh_id("knockout-shape");
self.def(1, &format!("<filter id=\"{f}\" filterUnits=\"userSpaceOnUse\" {BIG}>"));
self.def(1, &format!("<filter id=\"{f}\" filterUnits=\"userSpaceOnUse\" {region}>"));
self.def(2, "<feColorMatrix type=\"matrix\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0\"/>");
self.def(1, "</filter>");
self.knockout_filter = Some(f.clone());
self.knockout_filters.insert(region.to_string(), f.clone());
f
}
};
@@ -490,8 +622,8 @@ impl Writer<'_> {
let art = self.detached(3, |w| w.node(&shape));
self.anonymous = anonymous;
let mid = self.fresh_id("knockout");
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}>"));
self.def(2, &format!("<rect {BIG} fill=\"white\"/>"));
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {region}>"));
self.def(2, &format!("<rect {region} fill=\"white\"/>"));
self.def(2, &format!("<g filter=\"url(#{filter})\">"));
self.defs.push_str(&art);
self.def(2, "</g>");
+126 -2
View File
@@ -1,10 +1,11 @@
//! Blend modes and opacity masks in SVG export look as on the canvas: every mode follows the
//! reference formulas when resvg draws the file, and masks take luminance the canvas's way.
use usvg::roxmltree;
use vectorcraft_color::blend::blend_rgb;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, OpacityMask};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeKind, OpacityMask, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
use vectorcraft_svg::{ExportOptions, export};
use vectorcraft_testkit::raster::{Image, render_artboard};
@@ -138,3 +139,126 @@ fn a_knockout_group_with_a_brushed_stroke_keeps_its_masks() {
let svg = export(&d, &ExportOptions::default());
assert!(svg.contains("<mask"), "{svg}");
}
/// The `x`, `y`, `width` and `height` of each `<mask>` and `<filter>` element in `svg`.
fn regions(svg: &str) -> Vec<[f64; 4]> {
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
xml.descendants()
.filter(|n| matches!(n.tag_name().name(), "mask" | "filter"))
.map(|n| ["x", "y", "width", "height"].map(|a| n.attribute(a).and_then(|v| v.parse().ok()).unwrap_or(f64::NAN)))
.collect()
}
/// Mask and filter regions cover the art they apply to and no more: readers draw a mask into an
/// image of its region's size, and one drawn at 200,000 units square runs out of memory.
#[test]
fn mask_and_filter_regions_cover_their_art() {
let knockout = {
let mut d = Document::new(40.0, 20.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [200, 0, 0]);
let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), [0, 0, 200]);
top.opacity = 0.5;
let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
d
};
for (what, d) in [("clip", masked(true, false)), ("no clip, inverted", masked(false, true)), ("knockout", knockout)] {
let svg = export(&d, &ExportOptions::default());
let found = regions(&svg);
assert!(!found.is_empty(), "{what}: {svg}");
for [x, y, w, h] in found {
// The art is 40 × 20, with 2 to spare on each side.
assert!(x >= -2.5 && y >= -2.5 && w <= 45.0 && h <= 25.0 && w >= 40.0 && h >= 20.0, "{what}: {x} {y} {w} {h} in {svg}");
}
}
}
/// A mask's region reaches as far as the masked art paints: a drop shadow of the object, or of an
/// object in the masked group, shows past the object as on the canvas.
#[test]
fn a_mask_region_keeps_the_shadows_of_the_masked_art() {
let shadow = vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}),
visible: true,
};
for in_group in [false, true] {
let mut d = Document::new(60.0, 60.0);
let mut red = rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), [255, 0, 0]);
red.appearance.effects.push(shadow.clone());
let mut masked = if in_group { Node::group(d.alloc_id(), vec![red.into()]) } else { red };
// An unclipped mask lets the art show outside the mask art: all of the shadow shows.
let art = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 30.0), [255, 255, 255]);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
add(&mut d, masked);
let svg = export(&d, &ExportOptions::default());
let (canvas, written) = (render_artboard(&d), resvg_render(&svg, 60, 60));
for (x, y) in [(15, 15), (40, 40), (35, 28)] {
let (c, w) = (canvas.over_white(x, y), written.over_white(x, y));
assert!(within(c, w, 3), "in a group {in_group}, at {x},{y}: canvas {c:?}, svg {w:?}\n{svg}");
}
assert!(canvas.over_white(40, 40)[0] < 100, "the shadow shows on the canvas: {:?}", canvas.over_white(40, 40));
}
}
/// Type is measured by its layout or its path, and a reader sets live type in its own font, so a
/// mask or knockout over type keeps the region that covers any artwork.
#[test]
fn mask_and_filter_regions_over_type_cover_any_artwork() {
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
let text = |d: &mut Document| Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 30.0), "Wide", style.clone()))));
let masked = {
let mut d = Document::new(80.0, 40.0);
let mut t = text(&mut d);
let art = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [255, 255, 255]);
t.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
add(&mut d, t);
d
};
let knockout = {
let mut d = Document::new(80.0, 40.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 80.0, 40.0), [200, 0, 0]);
let mut top = text(&mut d);
top.opacity = 0.5;
let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
d
};
for (what, d) in [("masked type", masked), ("type in a knockout group", knockout)] {
let svg = export(&d, &ExportOptions::default());
let found = regions(&svg);
assert!(!found.is_empty(), "{what}: {svg}");
for [x, y, w, h] in found {
assert!(x <= -100000.0 && y <= -100000.0 && w >= 200000.0 && h >= 200000.0, "{what}: {x} {y} {w} {h} in {svg}");
}
}
}
/// A live object under a mask is measured as it is written, expanded: the mask's region covers
/// the drop shadows of a blend's steps.
#[test]
fn a_mask_region_covers_a_blend_as_written() {
let shadow = vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}),
visible: true,
};
let mut d = Document::new(100.0, 50.0);
let mut a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), [255, 0, 0]);
let mut b = rect(&mut d, Rect::new(70.0, 10.0, 80.0, 20.0), [0, 0, 255]);
a.appearance.effects.push(shadow.clone());
b.appearance.effects.push(shadow);
let mut blend = Node::new(d.alloc_id(), NodeKind::Blend { children: vec![a.into(), b.into()], spec: Default::default() });
let art = rect(&mut d, Rect::new(0.0, 0.0, 5.0, 5.0), [255, 255, 255]);
blend.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
add(&mut d, blend);
let svg = export(&d, &ExportOptions::default());
// The steps' drop shadows are filters with regions of their own; the mask's covers them all:
// the steps run from 10 to 80 across, and their shadows 15 further.
let xml = roxmltree::Document::parse_with_options(&svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
let mask = xml.descendants().find(|n| n.has_tag_name("mask")).unwrap();
let [x, y, w, h] = ["x", "y", "width", "height"].map(|a| mask.attribute(a).and_then(|v| v.parse::<f64>().ok()).unwrap_or(f64::NAN));
assert!(x <= 0.0 && y <= 0.0 && x + w >= 95.0 && y + h >= 35.0 && w < 200.0 && h < 200.0, "{x} {y} {w} {h} in {svg}");
}
+21
View File
@@ -55,6 +55,27 @@ fn an_inverted_unclipped_mask_round_trips() {
}
}
/// Mask art that reaches past the masked object comes back whole: the mask's region covers the art
/// as well as the object, so import does not clip the art to the region.
#[test]
fn mask_art_larger_than_its_object_round_trips_unclipped() {
let mut d = Document::new(200.0, 200.0);
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let white = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0);
let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), red);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 20.0, 180.0, 170.0)), white);
n.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let s = export(&d, &ExportOptions::default());
let (back, warnings) = import_with_report(&s).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let [m] = &masks(&back)[..] else { panic!("one masked object: {s}") };
assert!(m.art.path_data().is_some(), "the art alone, not clipped by the region: {:?}\n{s}", m.art.kind);
let b = m.art.geometric_bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6 && (b.y1 - 170.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn foreign_masks_keep_their_art() {
// Without the attribute a white backdrop is part of the mask art (another app's file).
+25
View File
@@ -296,6 +296,31 @@ fn masks_in_symbols_move_with_the_art_when_linked() {
}
}
/// A masked object in a symbol's art, drawn by an instance that scales it down: its stroke keeps
/// its weight, and the mask's region still covers all of it.
#[test]
fn a_mask_in_a_scaled_instance_covers_the_strokes_at_their_weight() {
let mut d = Document::new(120.0, 120.0);
let stroked = Appearance::basic(Paint::solid(Color::rgb8(200, 0, 0)), Paint::solid(Color::BLACK), 20.0);
let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), stroked);
let shape = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 100.0)),
Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0),
);
art.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(shape, false)));
d.symbols.push(Symbol { dynamic: false, name: "Framed".into(), art: Arc::new(art) });
instance(&mut d, "Framed", Affine::translate((50.0, 50.0)) * Affine::scale(0.2));
let svg = export(&d, &ExportOptions::default());
let (canvas, written) = (render_artboard(&d), resvg_render(&svg, 120, 120));
// The stroke runs 10 either side of the 20 × 20 square's edge at (50, 50).
for (x, y) in [(43, 60), (77, 60), (60, 43), (60, 77)] {
let (c, w) = (canvas.over_white(x, y), written.over_white(x, y));
assert!(c.iter().all(|v| *v < 60), "the stroke shows on the canvas at {x},{y}: {c:?}");
assert!(c.iter().zip(w).all(|(a, b)| (*a as i32 - b as i32).abs() <= 8), "at {x},{y}: canvas {c:?}, svg {w:?}\n{svg}");
}
}
#[test]
fn symbols_inside_themselves_end() {
let mut d = symbol_doc(1.0, &[Affine::translate((50.0, 50.0))]);
+5
View File
@@ -547,6 +547,11 @@ impl Tool for SmartPenTool {
key == ToolKey::Tab && (self.press.is_some() || self.active(cx).is_some())
}
/// A path being drawn: Escape ends it.
fn ends_on_escape(&self, _cx: &ToolContext) -> bool {
self.path.is_some()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.stop();
self.marquee = None;
+9
View File
@@ -178,6 +178,10 @@ pub struct ToolContext<'a> {
pub raster_sample: u32,
/// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included).
pub preview_bounds: bool,
/// Type › Measure Type by Glyph Bounds: the bounding box measures each point type object
/// selected as an object (not inside a selected group) by the ink of its glyphs
/// ([`vectorcraft_effects::preview::Measure`]).
pub glyph_bounds: bool,
/// The session's memo of the selection tools' measurements for the document as it is
/// ([`select::PreviewMemo`]); without one they measure on every call.
pub preview_memo: Option<&'a select::PreviewMemo>,
@@ -284,6 +288,11 @@ pub struct ToolContext<'a> {
}
impl ToolContext<'_> {
/// How the bounding box measures the selection: [`Self::preview_bounds`] and
/// [`Self::glyph_bounds`].
pub fn bounds_measure(&self) -> vectorcraft_effects::preview::Measure {
vectorcraft_effects::preview::Measure { preview: self.preview_bounds, glyphs: self.glyph_bounds }
}
/// Tolerance in document units for `px` screen pixels.
pub fn tol(&self, px: f64) -> f64 {
px / self.zoom.max(1e-9)
+124 -12
View File
@@ -67,8 +67,13 @@ pub struct DimensionTool {
snap: crate::guides::DrawSnap,
/// Where the next Linear pick lands, as last worked out for the pointer (for the overlays).
snapped: Option<(Point, Option<PointRef>)>,
/// Whether the press under way began on an anchor a Linear click picks.
press_on_anchor: bool,
}
/// How near an anchor (screen pixels) a pick snaps to it.
const ANCHOR_REACH: f64 = 8.0;
/// `at` as a Shift click picks it: the direction from `last` snapped to 45° steps (from the
/// constrain angle), the distance kept.
fn constrained(cx: &ToolContext, last: Point, at: Point) -> Point {
@@ -119,10 +124,45 @@ fn anchor_near(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<(usize,
[edge.segment, next]
.into_iter()
.filter_map(|i| Some((i, sp.anchors.get(i)?.p)))
.filter(|(_, a)| a.distance(p) <= cx.tol(8.0))
.filter(|(_, a)| a.distance(p) <= cx.tol(ANCHOR_REACH))
.min_by(|(_, a), (_, b)| a.distance(p).total_cmp(&b.distance(p)))
}
/// The anchor nearest `p` within reach, on any editable path (the topmost on a tie), as a link to
/// its path. Bounded work, and dimension artwork is skipped, as in [`pick`].
fn anchor_under(cx: &ToolContext, p: Point) -> Option<(Point, PointRef)> {
type Best = Option<(f64, Point, PointRef)>;
fn visit(n: &Node, p: Point, reach: f64, budget: &mut usize, best: &mut Best) {
if *budget == 0 || !n.visible || n.locked || n.extras().dimension.is_some() {
return;
}
if let Some(path) = n.path_data() {
for (subpath, sp) in path.subpaths.iter().enumerate() {
for (anchor, a) in sp.anchors.iter().enumerate() {
if *budget == 0 {
return;
}
*budget -= 1;
let d = a.p.distance(p);
if d <= reach && best.as_ref().is_none_or(|(nearest, ..)| d < *nearest) {
*best = Some((d, a.p, PointRef { node: n.id, subpath, anchor }));
}
}
}
}
if let Some(ch) = n.children() {
for c in ch.iter().rev() {
visit(c, p, reach, budget, best);
}
}
}
let (reach, mut budget, mut best) = (cx.tol(ANCHOR_REACH), 20_000usize, None);
for n in cx.doc.layers.iter().rev() {
visit(n, p, reach, &mut budget, &mut best);
}
best.map(|(_, at, r)| (at, r))
}
/// The point of `edge` closest to `p`.
fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<Point> {
use vectorcraft_geom::kurbo::ParamCurveNearest;
@@ -138,25 +178,22 @@ fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<Point> {
}
impl DimensionTool {
/// Where a Linear pick at `p` lands: on an anchor of the line under the pointer, within reach
/// (kept as a link to its path); else where Smart Guides put it (another object's anchor or
/// centre, in line with one, or for the second point on a construction guide through the
/// first), its guides showing; else on the closest point of that line; else `p` itself.
/// Where a Linear pick at `p` lands: on the nearest anchor of any path, within reach (kept as
/// a link to its path); else where Smart Guides put it (another object's anchor or centre, in
/// line with one, or for the second point on a construction guide through the first), its
/// guides showing; else on the closest point of the line under the pointer; else `p` itself.
fn snap_linear(&mut self, cx: &ToolContext, p: Point) -> (Point, Option<PointRef>) {
let leave = self.points.last().map(|(first, _)| crate::guides::Leave::segment(cx, *first, false));
let guided = self.snap.hover(cx, p, &[], leave.as_ref());
let edge = pick(cx, p);
if let Some(edge) = &edge
&& let Some((anchor, point)) = anchor_near(cx, edge, p)
{
if let Some((point, reference)) = anchor_under(cx, p) {
self.snap.clear();
return (point, Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }));
return (point, Some(reference));
}
if guided.distance(p) > 1e-9 {
return (guided, None);
}
// Not on an anchor or a guide: snap to the closest point of the line (#2432).
(edge.and_then(|e| nearest_on(cx, &e, p)).unwrap_or(p), None)
(pick(cx, p).and_then(|e| nearest_on(cx, &e, p)).unwrap_or(p), None)
}
fn target(&self, cx: &ToolContext, p: Point) -> Option<Target> {
let edge = pick(cx, p)?;
@@ -243,6 +280,7 @@ impl DimensionTool {
fn reset(&mut self) {
self.pending = None;
self.press = None;
self.press_on_anchor = false;
self.points.clear();
self.lines.clear();
self.dragging = false;
@@ -355,6 +393,10 @@ impl Tool for DimensionTool {
self.press = Some((ev.pos, None));
return self.preview(cx, ev.pos);
}
// A Linear press on an anchor picks the anchor unless it drags past the anchor's
// reach: moving a few pixels before the release does not drag out the dimension of
// a segment under the pointer.
self.press_on_anchor = self.kind == Kind::Linear && anchor_under(cx, ev.pos).is_some();
self.press = Some((ev.pos, self.target(cx, ev.pos)));
vec![]
}
@@ -364,7 +406,7 @@ impl Tool for DimensionTool {
}
if self.pending.is_none()
&& let Some((start, Some(target))) = self.press.clone()
&& start.distance(ev.pos) > cx.tol(3.0)
&& start.distance(ev.pos) > cx.tol(if self.press_on_anchor { ANCHOR_REACH } else { 3.0 })
&& self.points.is_empty()
&& self.lines.is_empty()
{
@@ -543,6 +585,76 @@ mod tests {
assert!(matches!(placed.last(), Some(Action::Commit)), "{placed:?}");
}
/// A Linear press on an anchor picks that anchor even when the pointer moves a few pixels
/// before the release: it does not drag out the dimension of a segment that ends there. Both
/// picks keep their links to the path, so the dimension measures between the two anchors.
#[test]
fn a_press_on_an_anchor_picks_it_when_the_pointer_moves() {
let (d, id) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y);
let mut t = DimensionTool::default();
for ((x, y), (dx, dy)) in [((100.0, 100.0), (3.5, 1.0)), ((200.0, 200.0), (-4.0, -2.0))] {
assert!(t.pointer(&c, &ev(PointerKind::Down, x, y)).is_empty());
assert!(t.pointer(&c, &ev(PointerKind::Drag, x + dx, y + dy)).is_empty(), "no segment dimension is dragged out");
assert!(t.pointer(&c, &ev(PointerKind::Up, x + dx, y + dy)).is_empty(), "nothing is committed");
}
let d = t.pending.as_ref().expect("both anchors were picked");
assert_eq!((d.a, d.b), (Point::new(100.0, 100.0), Point::new(200.0, 200.0)));
let anchor = |anchor| Some(PointRef { node: id, subpath: 0, anchor });
assert_eq!(d.source, Some(DimensionSource::Points { a: anchor(0), b: anchor(2), vertex: None }));
// Dragged past the anchor's reach, the press drags out the dimension of a segment there.
let mut t = DimensionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0));
assert!(t.pointer(&c, &ev(PointerKind::Drag, 103.0, 106.0)).is_empty());
let acts = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 60.0));
assert!(matches!(acts.first(), Some(Action::Begin(_))), "{acts:?}");
assert!(matches!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 60.0)).last(), Some(Action::Commit)));
}
/// The anchors of a live rectangle are picked the same way.
#[test]
fn a_press_on_a_live_shapes_anchor_picks_it() {
let (d, id) = doc_with_live_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = DimensionTool::default();
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 100.0));
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 197.0, 102.0));
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 197.0, 102.0));
let [(at, Some(r))] = t.points.as_slice() else { panic!("{:?}", t.points) };
assert_eq!((*at, r.node), (Point::new(200.0, 100.0), id));
}
/// An anchor of a compound path inside nested groups, lying just inside another object's
/// edge, is picked and linked, though that edge is nearer the pointer than any of the
/// compound path's own segments. A press there that moves a few pixels picks it too.
#[test]
fn an_anchor_under_another_objects_edge_is_picked() {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, NodeKind};
use vectorcraft_geom::Rect;
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let (outer, inner, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let mut path = vectorcraft_geom::shapes::rectangle(Rect::new(20.0, 20.0, 40.0, 40.0));
path.subpaths.extend(vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 103.0, 180.0, 130.0)).subpaths);
let art = Node::path(compound, path, Appearance::default_art());
let inner_group = Node::new(inner, NodeKind::Group { children: vec![Arc::new(art)], clip: false });
let group = Node::new(outer, NodeKind::Group { children: vec![Arc::new(inner_group)], clip: false });
d.insert(Some(l), 0, group).unwrap();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let corner = (Point::new(150.0, 103.0), Some(PointRef { node: compound, subpath: 1, anchor: 0 }));
let mut t = DimensionTool::default();
assert_eq!(t.snap_linear(&c, Point::new(151.0, 101.0)), corner);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 151.0, 101.0));
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 155.0, 100.0)).is_empty(), "no segment dimension is dragged out");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 155.0, 100.0));
assert_eq!(t.points, vec![corner]);
}
#[test]
fn placement_press_previews_without_a_prior_move() {
let (d, _) = doc_with_rect();
+2 -2
View File
@@ -1437,7 +1437,7 @@ impl DirectSelectionTool {
// them, an edge on a gridline on each axis), not that point (#3835).
let grid = segment && (cx.snap_to_grid || cx.snap_to_pixel);
self.partial_grid =
grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds)));
grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure())));
}
let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
let unsnapped = d;
@@ -1474,7 +1474,7 @@ impl DirectSelectionTool {
self.partial_snap = (!cx.selection.anchors.is_empty()).then(|| PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
let grid = self.partial_snap.is_some() && grab.is_none() && (cx.snap_to_grid || cx.snap_to_pixel);
self.partial_grid =
grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds)));
grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure())));
let copy = ev.mods.alt && self.partial_snap.is_none();
out.push(Action::Begin(if copy { "Copy".into() } else { "Move".into() }));
self.state = State::MoveObject { start, grab, hold, began: true, deselect: None };
+4
View File
@@ -649,6 +649,10 @@ impl Tool for CurvatureTool {
self.id = Some(cx.selection.objects[0]);
}
}
/// A path being drawn: Escape ends it.
fn ends_on_escape(&self, cx: &ToolContext) -> bool {
self.active(cx).is_some()
}
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Backspace | ToolKey::Delete)
&& self.drag.is_none()
+7
View File
@@ -162,6 +162,12 @@ pub trait Tool: Send {
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
false
}
/// Has the tool started something that Escape ends while no button is pressed (a path being
/// drawn, the Blend tool's first pick)? That Escape ends nothing else, such as isolation mode or
/// a rotated view.
fn ends_on_escape(&self, _cx: &ToolContext) -> bool {
false
}
/// Does the tool want typed text (Type tool editing)? Single-key shortcuts are suppressed.
fn wants_text(&self) -> bool {
false
@@ -536,6 +542,7 @@ pub(crate) mod testutil {
freeform_point: None,
raster_sample: 1,
preview_bounds: false,
glyph_bounds: false,
preview_memo: None,
unit: Unit::Points,
stroke_unit: Unit::Points,
+6
View File
@@ -153,6 +153,10 @@ impl Tool for BlendTool {
fn busy(&self) -> bool {
self.first.is_some()
}
/// A first pick, or adding to the blend just made: Escape ends either.
fn ends_on_escape(&self, cx: &ToolContext) -> bool {
self.first(cx).is_some()
}
/// Escape drops the object picked first and stops adding to the blend just made, so the next
/// click picks a new first object. The document and the selection stay as they are.
fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec<Action> {
@@ -525,8 +529,10 @@ mod tests {
let c = cx(&d, &s, &p);
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
assert!(t.first(&c).is_some());
assert!(!t.busy() && t.ends_on_escape(&c), "Escape has adding to the blend to end");
assert!(t.key(&c, crate::ToolKey::Escape, Mods::default()).is_empty());
assert!(t.first(&c).is_none(), "Escape ends adding to the blend just made");
assert!(!t.ends_on_escape(&c), "and then has nothing to end");
assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a));
assert!(esc(&mut BlendTool::default()).is_empty(), "nothing to drop");
}
+4
View File
@@ -242,6 +242,10 @@ impl Tool for PenTool {
fn busy(&self) -> bool {
self.drag.is_some() || self.handle.is_some() || self.convert.is_some()
}
/// A path being drawn: Escape ends it.
fn ends_on_escape(&self, _cx: &ToolContext) -> bool {
self.path.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let tol = cx.tol(5.0).max(cx.point_tol());
// The path a press began is the selected one by the next event.
+8 -7
View File
@@ -419,7 +419,7 @@ struct MemoState {
struct Measured {
revision: (u64, u64),
objects: Vec<NodeId>,
preview: bool,
measure: vectorcraft_effects::preview::Measure,
bounds: Option<Option<Rect>>,
oriented: Option<Option<OrientedBox>>,
by_object: HashMap<NodeId, Option<Option<Rect>>>,
@@ -427,7 +427,7 @@ struct Measured {
impl Measured {
fn is_for(&self, cx: &ToolContext) -> bool {
self.revision == cx.revision && self.preview == cx.preview_bounds && self.objects == cx.selection.objects
self.revision == cx.revision && self.measure == cx.bounds_measure() && self.objects == cx.selection.objects
}
}
@@ -450,7 +450,7 @@ impl PreviewMemo {
let fresh = || Measured {
revision: cx.revision,
objects: cx.selection.objects.clone(),
preview: cx.preview_bounds,
measure: cx.bounds_measure(),
bounds: None,
oriented: None,
by_object: HashMap::new(),
@@ -487,15 +487,16 @@ fn remembered<T: Clone>(cx: &ToolContext, slot: impl Fn(&mut Measured) -> &mut O
}
/// Selection bounds used for the bounding box: visual bounds as drawn with Use Preview Bounds
/// ([`vectorcraft_effects::preview`]), else geometric bounds.
/// ([`vectorcraft_effects::preview`]), else geometric bounds, with point type measured by its
/// glyphs with Measure Type by Glyph Bounds ([`ToolContext::bounds_measure`]).
pub fn selection_bounds(cx: &ToolContext) -> Option<Rect> {
remembered(cx, |m| &mut m.bounds, || vectorcraft_effects::preview::bounds_of(cx.doc, &cx.selection.objects, cx.preview_bounds))
remembered(cx, |m| &mut m.bounds, || vectorcraft_effects::preview::bounds_of_with(cx.doc, &cx.selection.objects, cx.bounds_measure()))
}
/// [`selection_bounds`] square to the selection's own angle: the bounding box the Selection tool
/// shows and drags (rotated after a rotation).
pub fn selection_box(cx: &ToolContext) -> Option<OrientedBox> {
remembered(cx, |m| &mut m.oriented, || vectorcraft_effects::preview::oriented_bounds(cx.doc, &cx.selection.objects, cx.preview_bounds))
remembered(cx, |m| &mut m.oriented, || vectorcraft_effects::preview::oriented_bounds_with(cx.doc, &cx.selection.objects, cx.bounds_measure()))
}
/// [`selection_box`] while the canvas shows it (View › Show Bounding Box, and no anchors selected
@@ -527,7 +528,7 @@ fn object_bounds(cx: &ToolContext, id: NodeId) -> Option<Rect> {
/// which compares the whole selection: a walk of every selected object checking it each time takes
/// time in the square of their number.
fn with_object_bounds<R>(cx: &ToolContext, walk: impl FnOnce(&mut dyn FnMut(NodeId) -> Option<Rect>) -> R) -> R {
let mut measure = |id: NodeId| vectorcraft_effects::preview::bounds_of(cx.doc, &[id], cx.preview_bounds);
let mut measure = |id: NodeId| vectorcraft_effects::preview::bounds_of_with(cx.doc, &[id], cx.bounds_measure());
let Some(mut st) = cx.preview_memo.and_then(|memo| memo.0.try_borrow_mut().ok()) else {
return walk(&mut measure);
};
+24
View File
@@ -2255,6 +2255,30 @@ fn the_preview_memo_measures_once_per_revision_selection_and_preview_bounds() {
assert_eq!(selection_bounds(&c).map(|r| r.x1), Some(250.0));
}
/// With Measure Type by Glyph Bounds, the bounding box of selected point type is the ink of its
/// glyphs; the memo measures again when only that setting changes.
#[test]
fn the_bounding_box_of_point_type_follows_measure_type_by_glyph_bounds() {
use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject};
let mut d = Document::new(500.0, 500.0);
let (l, id) = (d.layers[0].id, d.alloc_id());
let text = TextObject::point(Point::new(100.0, 300.0), "Hg", CharStyle { size: 60.0, ..Default::default() });
let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text).ink_bounds().unwrap();
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap();
let (mut s, p, memo) = (Selection::default(), paint(), PreviewMemo::default());
s.set([id]);
let at = |glyph_bounds| {
let c = ToolContext { revision: (1, 1), glyph_bounds, preview_memo: Some(&memo), ..cx(&d, &s, &p) };
(selection_box(&c).map(|b| b.rect), object_bounds(&c, id))
};
let em = d.node(id).and_then(|n| n.geometric_bounds());
assert_eq!(at(false), (em, em));
let glyphs = Some(text.xf.transform_rect_bbox(ink));
assert_eq!(at(true), (glyphs, glyphs));
assert_ne!(glyphs, em);
assert_eq!(memo.measurements(), 4, "the same revision measured again for the setting");
}
/// A double-click on a symbol instance edits its symbol in symbol editing mode (`symbol.edit`), and
/// one outside the symbol's art in that mode leaves it, as it leaves isolation mode. An instance
/// inside the art of the symbol being edited is not entered.
+1 -1
View File
@@ -118,7 +118,7 @@ impl TransformTool {
return Some(*p);
}
// Anchors selected with Direct Selection transform alone, about their own centre.
vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds)
vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure())
.map(|b| b.center())
.or_else(|| selection_box(cx).map(|b| b.center()))
}
+26 -6
View File
@@ -53,6 +53,8 @@ impl VectorcraftApp {
// The place cursor's clicks and drags place their files through the app
// ([`place::place_from_cursor`]).
session.place_cursor_requests = true;
// A script runs a slice per frame, so the window keeps drawing and Stop can be pressed.
session.script_frames = true;
let views = session.documents().iter().map(View::of).collect();
let view_uids = session.documents().iter().map(|d| d.uid).collect();
let known = session.documents().iter().map(|d| d.uid).collect();
@@ -232,6 +234,19 @@ impl VectorcraftApp {
/// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are
/// called from the host's `eframe::App` impl.
impl VectorcraftApp {
/// Applies the work that finished off the UI thread. It stays pending while a script runs, since
/// every change to the documents ends in a busy error until the script ends.
pub(crate) fn poll_finished_work(&mut self) {
if self.session.script_running() {
return;
}
background::poll(self);
opening::poll(self);
plugin_runs::poll(self);
trace_runs::poll(self);
placing::poll(self);
}
/// The language the UI is drawn in: the Preferences dialog's choice while it is open (so a
/// change shows before OK), else the `interfaceLanguage` preference (`auto` = the system's).
pub fn ui_language(&self) -> i18n::Lang {
@@ -321,12 +336,12 @@ impl VectorcraftApp {
// A Paste the host read before this iteration's formats were written must still wait for
// them, even if the worker below finishes them during this same tick.
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("clipboard.pending-at-tick"), self.clipboard_publishing.is_pending()));
background::poll(self);
screen_color::poll(self, ctx);
opening::poll(self);
plugin_runs::poll(self);
trace_runs::poll(self);
placing::poll(self);
self.poll_finished_work();
crate::script_runs::poll(self);
if self.session.script_running() && !self.session.script_waiting() {
ctx.request_repaint();
}
#[cfg(not(target_arch = "wasm32"))]
self.tick_link_updates(ctx, now);
crate::addons::select::link_status::refresh(self, now);
@@ -351,7 +366,12 @@ impl VectorcraftApp {
crate::ime::close_viewport(self, ctx);
self.ime_context.end_viewport(ctx.viewport_id());
unsaved::finish_pending(self);
if self.file_sheet_open() {
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!({})));
}
} else if self.file_sheet_open() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.status(picks::busy());
} else if unsaved::any_dirty(self) {
+3 -2
View File
@@ -137,13 +137,14 @@ impl VectorcraftApp {
/// frame: it is measured once per revision.
pub fn selection_box(&mut self) -> Option<vectorcraft_doc::OrientedBox> {
let st = self.session.active()?;
let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds);
let measure = self.session.bounds_measure();
let key = (st.uid, st.revision, measure);
if let Some((k, b)) = self.canvas.selection_box
&& k == key
{
return b;
}
let b = match vectorcraft_effects::preview::anchor_bounds(&st.selection, &st.doc, self.session.prefs.use_preview_bounds) {
let b = match vectorcraft_effects::preview::anchor_bounds_with(&st.selection, &st.doc, measure) {
Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)),
None => self.session.transform_box(&st.selection.objects),
};
+3 -2
View File
@@ -51,8 +51,9 @@ pub struct CanvasCache {
/// The selected objects' outlines, anchors and handles as drawn in the last frame. A frame that
/// does not draw them drops them ([`canvas::show`]).
pub selection_marks: canvas::SelectionMarks,
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds).
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds and
/// Measure Type by Glyph Bounds).
pub selection_box: Option<((u64, u64, vectorcraft_effects::preview::Measure), Option<vectorcraft_doc::OrientedBox>)>,
/// [`VectorcraftApp::selection_bounds`] for (document uid, revision).
pub selection_bounds: Option<((u64, u64), Option<vectorcraft_geom::Rect>)>,
/// [`VectorcraftApp::content_bounds`] for (document uid, revision).
+32 -14
View File
@@ -543,8 +543,9 @@ fn ruler_bands(app: &VectorcraftApp, ui: &Ui, xf: &Xf, (full, rect): (egui::Rect
let painter = ui.painter_at(rect);
if let Some(origin) = origin_preview {
let at = xf.to_screen(origin);
painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, t.text_dim));
painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, t.text_dim));
let color = dim_on_canvas(t, app.outline_view());
painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, color));
painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, color));
}
// View › Rulers › Show Video Rulers counts the active artboard in video pixels.
let video = app.ui.view.video_rulers.then(|| crate::dialogs::artboard_options::active_index(app));
@@ -793,8 +794,17 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
}
}
/// The pasteboard, the artboards' shadows and paper, and the outline mode's white artboards, of
/// document `st`, in Outline when `outline`.
/// The canvas color in Outline, whatever the interface brightness and Canvas Color: the paths draw
/// as black hairlines, and the artboards as their edges only.
const OUTLINE_CANVAS: Color32 = Color32::WHITE;
/// The color of the artboards' names and the ruler origin's crosshair in Outline, on
/// [`OUTLINE_CANVAS`].
const OUTLINE_DIM: Color32 = Color32::from_gray(0x4b);
/// The pasteboard and the artboards' shadows and paper of document `st`, or in Outline (`outline`)
/// the whole canvas in [`OUTLINE_CANVAS`], with no paper drawn for the artboards: no Simulate
/// Colored Paper color, no artboard background color, and no paper with View › Hide Artboards.
fn paint_paper(
app: &VectorcraftApp,
st: &vectorcraft_engine::DocState,
@@ -805,6 +815,12 @@ fn paint_paper(
(t, outline): (&Tokens, bool),
) {
let doc = &st.doc;
if outline {
// The art draws in black on white over the pasteboard and the artboards alike, and the
// artboards show as their edges (`artboard_outlines`).
painter.rect_filled(rect, 0.0, OUTLINE_CANVAS);
return;
}
// Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the
// simulated paper colour; a white Background Contents hides the grid).
// An artboard with a background color draws its paper in that color. With Simulate Colored Paper
@@ -815,7 +831,7 @@ fn paint_paper(
// View › Hide Artboards: no edges and no pasteboard, the whole canvas paper (#4038).
let setup = &doc.setup;
painter.rect_filled(rect, 0.0, if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE });
} else if !outline {
} else {
let setup = &doc.setup;
let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup));
let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE };
@@ -833,10 +849,6 @@ fn paint_paper(
}
}
}
} else {
for ab in &doc.artboards {
painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE));
}
}
}
@@ -1007,16 +1019,22 @@ fn artboard_outlines(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf:
let tool_selected = artboards::tool_labelled(app);
let editing_name = ui.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id()).map(|e| e.index));
let unlabelled = |i: usize| editing_name == Some(i) || u64::try_from(i).ok().is_some_and(|idx| tool_selected.contains(&idx));
artboard_edges(app, doc, (painter, xf, t), active_ab, unlabelled);
artboard_edges(app, doc, (painter, xf, t), (active_ab, dim_on_canvas(t, app.outline_view())), unlabelled);
}
/// The edges, bleeds, marks and names of `doc`'s artboards, `active_ab` the active one; the
/// artboards `unlabelled` gives are not named.
/// The color of the artboards' names and the ruler origin's crosshair over the pasteboard, or over
/// [`OUTLINE_CANVAS`] in Outline (`outline`).
fn dim_on_canvas(t: &Tokens, outline: bool) -> Color32 {
if outline { OUTLINE_DIM } else { t.text_dim }
}
/// The edges, bleeds, marks and names of `doc`'s artboards, `active_ab` the active one, the names
/// in `label`; the artboards `unlabelled` gives are not named.
fn artboard_edges(
app: &VectorcraftApp,
doc: &vectorcraft_doc::Document,
(painter, xf, t): (&egui::Painter, &Xf, &Tokens),
active_ab: usize,
(active_ab, label): (usize, Color32),
unlabelled: impl Fn(usize) -> bool,
) {
let artboard_tool = app.session.tool_id() == "artboard";
@@ -1034,7 +1052,7 @@ fn artboard_edges(
// At the artboard's own top-left corner, which a rotated view turns away from its
// bounding box's (#1095).
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
artboard_label(painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
artboard_label(painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), label);
}
}
}
+3 -2
View File
@@ -12,7 +12,8 @@ use super::outline::{c32, node_outline, stroke_path, too_many_anchors};
use super::view::fit_zoom;
use super::{
Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, cache_key,
frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, pos2, rulers, shows_artboards, textured_quad, vec2, view_rects,
dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, pos2, rulers, shows_artboards, textured_quad, vec2,
view_rects,
};
use crate::doc_windows::Win;
use crate::thumb_worker::ThumbWorker;
@@ -315,7 +316,7 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect)
grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look);
}
if shows_artboards(app) {
artboard_edges(app, &st.doc, (&painter, &xf, &t), v.artboard, |_| false);
artboard_edges(app, &st.doc, (&painter, &xf, &t), (v.artboard, dim_on_canvas(&t, v.outline)), |_| false);
}
if app.ui.view.guides {
guides_of(app, st, (&painter, &xf, &t), rect);
+218
View File
@@ -210,6 +210,27 @@ fn command_cursor_settings_refresh_hover_cursor_while_view_moves() {
assert_eq!(hover_cursor(&mut app, mods, view, false), Some(Cursor::Crosshair));
}
/// A script that changes Caps Lock refreshes the pointer when its run ends.
#[test]
fn a_script_run_refreshes_hover_cursor_while_view_moves() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.session.select_tool("pen", vectorcraft_engine::ViewInfo::default()).unwrap();
app.hover_doc = Some(Point::new(200.0, 150.0));
let mods = Mods::default();
let view = vectorcraft_engine::ViewInfo::default();
assert_eq!(hover_cursor(&mut app, mods, view, false), Some(Cursor::Pen));
app.run("script.run", json!({"source": "vc.run('view.capsLock', {on: true});"})).unwrap();
for _ in 0..1_000 {
if !app.session.script_running() {
break;
}
crate::script_runs::poll(&mut app);
}
assert!(app.session.caps_lock);
assert_eq!(hover_cursor(&mut app, mods, view, false), Some(Cursor::Crosshair));
}
/// #826: in a turned view each ruler still has ticks along its whole length; at 0° the top one
/// reads x where the canvas has it.
#[test]
@@ -4256,6 +4277,203 @@ fn outline_is_per_document_and_new_documents_start_in_preview() {
assert!(app.run("view.outline", json!({})).is_err());
}
/// One headless canvas frame → (the colors of the filled rectangles that cover the canvas, the
/// fills of the shapes laid over an artboard, the artboards' edges drawn, the colors of the
/// artboards' names).
fn canvas_look(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Vec<Color32>, Vec<Color32>, usize, Vec<Color32>) {
let s = shapes(app, ctx);
let canvas = app.canvas_rect.unwrap();
let xf = Xf::new(canvas, app.view().unwrap());
let quads: Vec<Vec<Pos2>> = app.session.active().unwrap().doc.artboards.iter().map(|a| xf.quad(a.rect).to_vec()).collect();
let on = |p: &[Pos2]| quads.iter().any(|q| q.len() == p.len() && q.iter().zip(p).all(|(a, b)| (*a - *b).length() < 0.01));
let covers: Vec<Color32> = s
.iter()
.filter_map(|s| match s {
Shape::Rect(r) if r.rect.contains_rect(canvas) => Some(r.fill),
_ => None,
})
.collect();
let papers: Vec<Color32> = s
.iter()
.filter_map(|s| match s {
Shape::Path(p) if p.fill != Color32::TRANSPARENT && on(&p.points) => Some(p.fill),
_ => None,
})
.collect();
let edges = s.iter().filter(|s| matches!(s, Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT && on(&p.points))).count();
let names: Vec<Color32> = s
.iter()
.filter_map(|s| match s {
Shape::Text(t) if names_an_artboard(t.galley.text()) => Some(t.fallback_color),
_ => None,
})
.collect();
(covers, papers, edges, names)
}
/// In Outline, in every interface brightness, color scheme and User Interface › Canvas Color, the
/// whole canvas is white, pasteboard included, so the black outlines of art on the pasteboard show.
/// The artboards draw no paper there, only their edges, and their names draw in a dark gray. In
/// Preview the pasteboard keeps its color and the artboards their white paper.
#[test]
fn outline_draws_on_a_white_canvas_with_artboards_as_edges() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
for brightness in crate::theme::Brightness::ALL {
for scheme in crate::theme::Scheme::ALL {
// Canvas Color: matching the brightness, Dark Gray and Medium Gray, as `prefs_dialog` sets it.
for canvas_color in [None, Some(Color32::from_gray(0x5a)), Some(Color32::from_gray(0x96))] {
let ctx = egui::Context::default();
crate::theme::apply_with(&ctx, brightness, Default::default(), scheme);
if let Some(c) = canvas_color {
let mut t = Tokens::get(&ctx);
t.pasteboard = c;
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
}
let case = format!("{brightness:?} {scheme:?} {canvas_color:?}");
let pasteboard = Tokens::get(&ctx).pasteboard;
assert_ne!(pasteboard, Color32::WHITE, "{case}");
app.view_mut().unwrap().outline = false;
let (covers, papers, edges, names) = canvas_look(&mut app, &ctx);
assert_eq!(covers.last(), Some(&pasteboard), "{case}: Preview keeps the pasteboard");
assert_eq!(papers, [Color32::WHITE; 2], "{case}: and the artboards' paper");
assert_eq!(edges, 2, "{case}");
assert_eq!(names, [Tokens::get(&ctx).text_dim; 2], "{case}");
app.view_mut().unwrap().outline = true;
let (covers, papers, edges, names) = canvas_look(&mut app, &ctx);
assert_eq!(covers.last(), Some(&Color32::WHITE), "{case}: the canvas is white in Outline");
assert!(!covers.contains(&pasteboard), "{case}: no pasteboard in Outline");
assert!(papers.is_empty(), "{case}: no paper over the artboards in Outline: {papers:?}");
assert_eq!(edges, 2, "{case}: the artboards show as their edges");
assert_eq!(names, [OUTLINE_DIM; 2], "{case}: names readable on white");
}
}
}
}
/// In Outline the canvas stays white with Simulate Colored Paper on, with an artboard's own
/// background color, and with View › Hide Artboards, which in Preview show the paper color, the
/// artboard's color and the paper color over the whole canvas.
#[test]
fn outline_draws_no_colored_paper() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
{
let doc = std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc);
doc.setup.simulate_paper = true;
doc.setup.grid_colors = [vectorcraft_color::Color::rgb(0.9, 0.8, 0.6), vectorcraft_color::Color::rgb(0.5, 0.5, 0.5)];
doc.artboards[1].color = Some(vectorcraft_color::Color::rgb(0.6, 0.8, 1.0));
}
let paper = crate::panels::c32(&app.session.active().unwrap().doc.setup.paper());
let ctx = egui::Context::default();
crate::theme::apply(&ctx, crate::theme::Brightness::Dark);
app.view_mut().unwrap().outline = false;
let (_, papers, _, _) = canvas_look(&mut app, &ctx);
assert_eq!(papers.len(), 2);
assert_eq!(papers[0], paper, "Preview: the simulated paper");
assert_ne!(papers[1], paper, "Preview: the artboard's own color");
app.view_mut().unwrap().outline = true;
let (covers, papers, edges, _) = canvas_look(&mut app, &ctx);
assert_eq!(covers.last(), Some(&Color32::WHITE));
assert!(papers.is_empty(), "{papers:?}");
assert_eq!(edges, 2);
app.ui.view.artboards = false;
app.view_mut().unwrap().outline = false;
let (covers, _, edges, _) = canvas_look(&mut app, &ctx);
assert_eq!(covers.last(), Some(&paper), "Preview with Hide Artboards: the paper over the whole canvas");
assert_eq!(edges, 0);
app.view_mut().unwrap().outline = true;
let (covers, papers, edges, _) = canvas_look(&mut app, &ctx);
assert_eq!(covers.last(), Some(&Color32::WHITE), "Outline with Hide Artboards");
assert!(!covers.contains(&paper));
assert!(papers.is_empty());
assert_eq!(edges, 0);
}
/// A split-view pane or floating window draws its own view's Outline, whatever the current view
/// shows: the white canvas and dark gray artboard names in Outline, and the pasteboard and the
/// theme's names in Preview.
#[test]
fn a_pane_draws_its_own_views_outline() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
crate::doc_windows::new_window(&mut app).unwrap();
let ctx = egui::Context::default();
crate::theme::apply(&ctx, crate::theme::Brightness::Dark);
let t = Tokens::get(&ctx);
let other = (0, 1);
for pane_outline in [true, false] {
crate::doc_windows::view_mut(&mut app, other).unwrap().outline = pane_outline;
app.view_mut().unwrap().outline = !pane_outline;
let cell = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0));
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
panes::pane(&mut app, ui, other, cell);
});
out.textures_delta.clear();
let mut s = vec![];
fn flat(s: Shape, out: &mut Vec<Shape>) {
match s {
Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)),
s => out.push(s),
}
}
out.shapes.into_iter().for_each(|c| flat(c.shape, &mut s));
let fills: Vec<Color32> = s.iter().filter_map(|s| if let Shape::Rect(r) = s { Some(r.fill) } else { None }).collect();
let names: Vec<Color32> = s
.iter()
.filter_map(|s| if let Shape::Text(x) = s { names_an_artboard(x.galley.text()).then_some(x.fallback_color) } else { None })
.collect();
let (canvas, name) = if pane_outline { (Color32::WHITE, OUTLINE_DIM) } else { (t.pasteboard, t.text_dim) };
assert_eq!(fills.first(), Some(&canvas), "pane in Outline: {pane_outline}");
assert_eq!(names, [name; 2], "pane in Outline: {pane_outline}");
}
}
/// While the rulers' origin is dragged out of their corner, its crosshair draws over the canvas in
/// the theme's dim text color in Preview, and in Outline in the dark gray that shows on the white
/// canvas.
#[test]
fn the_ruler_origin_crosshair_shows_on_the_outline_canvas() {
use egui::Event;
for outline in [false, true] {
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.view_mut().unwrap().outline = outline;
let ctx = egui::Context::default();
crate::theme::apply(&ctx, crate::theme::Brightness::Dark);
frame(&mut app, &ctx, vec![]);
frame(&mut app, &ctx, vec![]);
let rect = app.canvas_rect.unwrap();
let corner = pos2(rect.left() - RULER / 2.0, rect.top() - RULER / 2.0);
let to = rect.center();
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(corner)]);
frame(&mut app, &ctx, vec![button(corner, true)]);
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
let (s, mut delta) = frame_output(&mut app, &ctx);
delta.clear();
let lines: Vec<Color32> = s
.iter()
.filter_map(|s| match s {
Shape::LineSegment { points, stroke }
if (points[0].x - to.x).abs() < 1.5
&& (points[1].x - to.x).abs() < 1.5
&& points[0].y <= rect.top() + 1.0
&& points[1].y >= rect.bottom() - 1.0 =>
{
Some(stroke.color)
}
_ => None,
})
.collect();
let want = if outline { OUTLINE_DIM } else { Tokens::get(&ctx).text_dim };
assert_eq!(lines, [want], "Outline: {outline}");
frame(&mut app, &ctx, vec![button(to, false)]);
}
}
/// #3797: a preferences file saved while View › Outline was an interface setting still loads, with
/// its other view settings, and its Outline value does not put a document in Outline.
#[test]

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