diff --git a/ASSETS.md b/ASSETS.md
index 34d75235c6..5658882d48 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -267,6 +267,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/flip-vertical-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/folder-open.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/frame.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
+| `assets/icons/graduation-cap.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Learn panel, Explore tutorials button |
| `assets/icons/grid-3x3.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/group.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/hand.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
diff --git a/ROADMAP.md b/ROADMAP.md
index 3e8cfb9d34..b1f75b2735 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -126,6 +126,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
- 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged.
- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged.
- 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`).
+- 2026-11-11: Pattern libraries: Patterns › Grit Textures, a second built-in pattern library, holds eight seamless black textures (Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks) on tiles of 18 to 36 pt, each drawn the same way every time; marks that reach past a tile edge continue on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged.
- 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923).
- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922).
- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925).
diff --git a/assets/icons/graduation-cap.svg b/assets/icons/graduation-cap.svg
new file mode 100644
index 0000000000..fbce25edcc
--- /dev/null
+++ b/assets/icons/graduation-cap.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs
index 97d46b7cda..4ecb8948bb 100644
--- a/crates/doc/src/style_libs.rs
+++ b/crates/doc/src/style_libs.rs
@@ -156,6 +156,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[
BuiltinStyleLibrary { id: "outlines-rules", name: "Outlines and Rules", make: outlines_rules },
BuiltinStyleLibrary { id: "hand-drawn", name: "Hand-Drawn", make: hand_drawn },
BuiltinStyleLibrary { id: "textures", name: "Textures", make: textures },
+ BuiltinStyleLibrary { id: "painted-sketched", name: "Painted and Sketched", make: painted_sketched },
BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes },
BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects },
BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency },
@@ -323,6 +324,59 @@ fn hand_drawn() -> Vec {
]
}
+/// Painted and Sketched: one catalog paint or sketch effect over the whole appearance, so it
+/// repaints the fill and the stroke together (watercolor, dry brush, pastels, charcoal and ink).
+fn painted_sketched() -> Vec {
+ vec![
+ style(
+ "Washed Blue",
+ vec![F(fill("#8ab9c9"))],
+ vec![fx("artistic.watercolor", json!({"brushDetail": 8.0, "shadowIntensity": 2.0, "texture": 2.0}))],
+ ),
+ style(
+ "Bristle Ochre",
+ vec![F(fill("#c4975a")), S(stroke("#76533d", 1.0))],
+ vec![fx("artistic.dryBrush", json!({"brushSize": 4.0, "brushDetail": 7.0, "texture": 2.0}))],
+ ),
+ style(
+ "Pastel Heather",
+ vec![F(fill("#a78ca5"))],
+ vec![fx(
+ "artistic.roughPastels",
+ json!({
+ "strokeLength": 8.0,
+ "strokeDetail": 9.0,
+ "texture": "canvas",
+ "scaling": 100.0,
+ "relief": 5.0,
+ "lightDirection": "topLeft",
+ "invert": false
+ }),
+ )],
+ ),
+ style(
+ "Charcoal Shade",
+ vec![F(fill("#666666"))],
+ vec![fx("sketch.charcoal", json!({"thickness": 3.0, "detail": 3.0, "lightDarkBalance": 55.0}))],
+ ),
+ style(
+ "Ink Contour",
+ vec![F(fill("#d9e2df")), S(stroke("#26323b", 1.0))],
+ vec![fx("brushStrokes.inkOutlines", json!({"strokeLength": 6.0, "darkIntensity": 24.0, "lightIntensity": 8.0}))],
+ ),
+ style(
+ "Wet Ink",
+ vec![F(fill("#637b85"))],
+ vec![fx("brushStrokes.sumiE", json!({"strokeWidth": 7.0, "strokePressure": 5.0, "contrast": 20.0}))],
+ ),
+ style(
+ "Fine Pen",
+ vec![F(fill("#d8d4c6"))],
+ vec![fx("sketch.graphicPen", json!({"strokeLength": 6.0, "lightDarkBalance": 55.0, "strokeDirection": "rightDiagonal"}))],
+ ),
+ ]
+}
+
/// Textures: fills and strokes painted with the catalog's texture effects (grain, canvas and
/// burlap surfaces, craquelure, tiles, patchwork, stained glass and water paper).
fn textures() -> Vec {
@@ -603,6 +657,36 @@ mod tests {
assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn");
}
+ #[test]
+ fn painted_and_sketched_follows_textures_with_one_effect_per_style() {
+ let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect();
+ let at = ids.iter().position(|&id| id == "painted-sketched").expect("listed");
+ assert_eq!(ids[at - 1], "textures");
+ let lib = builtin_style_library("painted-sketched").unwrap();
+ assert_eq!(lib.name, "Painted and Sketched");
+ let names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect();
+ assert_eq!(names, ["Washed Blue", "Bristle Ochre", "Pastel Heather", "Charcoal Shade", "Ink Contour", "Wet Ink", "Fine Pen"]);
+ let effects: Vec<&str> = lib.styles.iter().flat_map(|g| g.appearance.effects.iter().map(|e| e.id.as_str())).collect();
+ assert_eq!(
+ effects,
+ [
+ "artistic.watercolor",
+ "artistic.dryBrush",
+ "artistic.roughPastels",
+ "sketch.charcoal",
+ "brushStrokes.inkOutlines",
+ "brushStrokes.sumiE",
+ "sketch.graphicPen"
+ ]
+ );
+ assert!(lib.styles.iter().all(|g| g.appearance.items.iter().all(|i| i.effects().is_empty())), "no effects on a fill or stroke");
+ // No other built-in library holds a style of the same name.
+ for b in STYLE_LIBRARIES.iter().filter(|b| b.id != "painted-sketched") {
+ let other = builtin_style_library(b.id).unwrap();
+ assert!(other.styles.iter().all(|g| !names.contains(&g.name.as_str())), "{}", b.id);
+ }
+ }
+
#[test]
fn vcstyles_round_trip() {
let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone();
diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs
index c4ec92b70b..5ed95b78e1 100644
--- a/crates/doc/src/text/tabs_lists.rs
+++ b/crates/doc/src/text/tabs_lists.rs
@@ -28,6 +28,15 @@ pub struct TabStop {
pub align_on: char,
}
+impl TabStop {
+ /// The largest stop position `text.tabs.set` and a paragraph style take, in points.
+ pub const MAX_POSITION: f64 = 100_000.0;
+ /// The most leader characters a stop repeats.
+ pub const MAX_LEADER: usize = 8;
+ /// The most stops `text.tabs.set` and a paragraph style take in one list.
+ pub const MAX_STOPS: usize = 256;
+}
+
fn default_align_on() -> char {
'.'
}
diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs
index 5c18f2d5f0..866d9b32f3 100644
--- a/crates/effects/src/catalog.rs
+++ b/crates/effects/src/catalog.rs
@@ -1,4 +1,7 @@
-use super::{CLASSIC_EXTRUDE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap};
+use super::{
+ CLASSIC_EXTRUDE, CLASSIC_REVOLVE, CLASSIC_ROTATE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value,
+ json, trap,
+};
/// Catalogue entry for one effect.
#[derive(Clone, Debug, PartialEq)]
@@ -81,6 +84,7 @@ fn lengths_of(id: &str) -> Lengths {
"threeD.revolve" => always(&["offset"]),
"threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]),
"threeD.classicExtrude" => always(&["depth", "bevelHeight"]),
+ "threeD.classicRevolve" => always(&["offset"]),
"threeD.inflate" => always(&["depth"]),
"path.outlineStroke" => always(&["width"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
@@ -207,6 +211,20 @@ pub fn effect_catalog() -> Vec {
"{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.",
json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}),
),
+ g(
+ CLASSIC_REVOLVE,
+ "Revolve (Classic)…",
+ &["Effect", "3D and Materials", "3D (Classic)"],
+ "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.",
+ json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}),
+ ),
+ g(
+ CLASSIC_ROTATE,
+ "Rotate (Classic)…",
+ &["Effect", "3D and Materials", "3D (Classic)"],
+ "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.",
+ json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}),
+ ),
g(
"convertToShape.rectangle",
"Rectangle…",
diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs
index 67b4b464fd..92ee086147 100644
--- a/crates/effects/src/lib.rs
+++ b/crates/effects/src/lib.rs
@@ -83,12 +83,14 @@ pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art,
has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
};
-pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in};
+pub use revolve::{
+ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in,
+};
pub use solids::{
- CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options,
- classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in,
- three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, validate_three_d, validate_three_d_at,
- validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options,
+ CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES,
+ classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, is_three_d, solid_art, solid_art_in,
+ three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in,
+ validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options,
};
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs
index 4795e8848a..e7fd5e5f22 100644
--- a/crates/effects/src/revolve.rs
+++ b/crates/effects/src/revolve.rs
@@ -1,4 +1,5 @@
-//! Revolve as live, shaded vector art, shared by the canvas and every exporter.
+//! Revolve and Revolve (Classic) as live, shaded vector art, shared by the canvas and every
+//! exporter.
use std::sync::Arc;
use serde_json::Value;
@@ -13,6 +14,14 @@ use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params};
pub const REVOLVE: &str = "threeD.revolve";
+mod classic;
+pub use classic::CLASSIC_REVOLVE;
+
+/// Is `id` Revolve or Revolve (Classic), the effects this module draws?
+pub fn is_revolve(id: &str) -> bool {
+ id == REVOLVE || id == CLASSIC_REVOLVE
+}
+
/// Most bytes the copies of the object's paint in the faces may take together. Each face and the
/// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of
/// thousands of faces took gigabytes; past this the source path is drawn.
@@ -55,7 +64,7 @@ pub(crate) fn paint_copies_add(used: &mut usize, copies: usize, paint: &Paint) -
}
fn visible(fx: &[Effect]) -> bool {
- fx.iter().any(|e| e.visible && e.id == REVOLVE)
+ fx.iter().any(|e| e.visible && is_revolve(&e.id))
}
pub fn has_revolve(n: &Node) -> bool {
@@ -88,6 +97,17 @@ pub fn revolve_options(p: &Value) -> Revolve {
}
}
+/// The options Revolve (`id` [`REVOLVE`]) or Revolve (Classic) ([`CLASSIC_REVOLVE`]) with
+/// parameters `p` sweeps its surface with.
+fn options_of(id: &str, p: &Value) -> Revolve {
+ if id == CLASSIC_REVOLVE { classic::options(p) } else { revolve_options(p) }
+}
+
+/// The options effect `fx`, a Revolve or a Revolve (Classic), sweeps its surface with.
+fn fx_options(fx: &Effect) -> Revolve {
+ options_of(&fx.id, &merged_params(&fx.id, &fx.params))
+}
+
/// Validate before a command mutates the document. Loading external files is still bounded
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
@@ -97,11 +117,16 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
/// [`validate_revolve`] with the symbols of `doc`: a symbol instance, a blend and a clipping group
/// revolve the art they show ([`resolve_for`]), cut for Revolve whatever 3D effect `n` carries now.
pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Result<(), String> {
- validate_revolve_params(p)?;
+ validate_in(doc, n, REVOLVE, p)
+}
+
+/// [`validate_revolve_in`] for Revolve or Revolve (Classic), `id`.
+pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> {
+ validate_params_of(id, p)?;
let n = &*resolve_for(doc, n, "Revolve", false)?;
let effects = &n.appearance.effects;
// The profile is measured before the object's one Revolve, hidden or shown.
- let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects);
+ let before = effects.iter().position(|e| is_revolve(&e.id)).and_then(|i| effects.get(..i)).unwrap_or(effects);
if is_container(n) {
const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors";
let mut shapes = members(n, "Revolve")?;
@@ -110,7 +135,7 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul
}
reshape_members(n, before, &mut shapes);
let b = union_bounds(&shapes).ok_or(EMPTY)?;
- let options = shared_options(&shapes, revolve_options(p))?;
+ let options = shared_options(&shapes, options_of(id, p))?;
let mut copies = 0;
shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?;
let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0));
@@ -123,8 +148,8 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul
let path = geometry(&path, before, &GeomContext::of(n));
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let paints = solid_paints(n);
- check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)?;
- check_shadow(p, [&path], b, revolve_options(p))
+ check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?;
+ check_shadow(p, [&path], b, options_of(id, p))
}
/// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast
@@ -138,14 +163,21 @@ fn check_shadow<'a>(p: &Value, paths: impl IntoIterator- , b:
}
/// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of
-/// appearance item `target` of `n`. The surfaces of every fill and stroke that carries a Revolve,
-/// `target`'s included, share one budget of paint copies, as in [`art`]. A visible object-level
-/// Revolve draws in place of those surfaces.
+/// appearance item `target` of `n` ([`validate_item_of`]).
+#[cfg(test)]
pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> {
+ validate_item_of(n, REVOLVE, p, target, index)
+}
+
+/// [`validate_revolve`] for a Revolve or Revolve (Classic), `id`, with parameters `p` at index
+/// `index` of the effects of appearance item `target` of `n`. The surfaces of every fill and stroke
+/// that carries one, `target`'s included, share one budget of paint copies, as in [`art`]. A visible
+/// object-level Revolve draws in place of those surfaces.
+pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, index: usize) -> Result<(), String> {
if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() {
- return validate_revolve(n, p);
+ return validate_in(None, n, id, p);
}
- validate_revolve_params(p)?;
+ validate_params_of(id, p)?;
const EMPTY: &str = "Revolve requires a shape with at least two anchors";
let (path, _) = object_profile(n, "Revolve")?;
if !has_two_anchors(&path) {
@@ -156,13 +188,13 @@ pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: u
let mut copies = 0;
for (i, item) in n.appearance.items.iter().enumerate() {
let effects = item.effects();
- let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) };
- let params = match at.and_then(|at| effects.get(at)) {
- _ if i == target => p.clone(),
- Some(fx) if crate::paints(item) => merged_params(REVOLVE, &fx.params),
+ let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && is_revolve(&e.id)) };
+ let (kind, params) = match at.and_then(|at| effects.get(at)) {
+ _ if i == target => (id, p.clone()),
+ Some(fx) if crate::paints(item) => (fx.id.as_str(), merged_params(&fx.id, &fx.params)),
_ => continue,
};
- let options = revolve_options(¶ms);
+ let options = options_of(kind, ¶ms);
let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item));
let b = path.bounds().ok_or(EMPTY)?;
check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?;
@@ -217,6 +249,16 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> {
Ok(())
}
+/// [`validate_revolve_params`] for Revolve or Revolve (Classic), `id`; Revolve (Classic) also takes
+/// only its own parameters, each in its range.
+pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> {
+ validate_revolve_params(p)?;
+ if id == CLASSIC_REVOLVE {
+ classic::validate(p)?;
+ }
+ Ok(())
+}
+
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
}
@@ -226,7 +268,7 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
/// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when
/// the surface it is cast from is past the limits.
fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: Rect, options: Revolve) -> Result
, String> {
- let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(None) };
+ let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(None) };
let (_, opacity, blend) = paint_of(item);
if opacity <= 0.0 {
return Ok(None);
@@ -242,7 +284,7 @@ fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b:
/// ([`crate::solids::shadow::scoped_art`]). An error when the surfaces it is cast from are past the
/// limits.
fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: Revolve) -> Result>, String> {
- let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(vec![]) };
+ let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(vec![]) };
let mut caster = vectorcraft_three_d::ShadowCaster::new();
let mut cast = vec![];
for s in shapes {
@@ -259,7 +301,8 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R
}
/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts
-/// the bytes of the paint copies of every surface of the object.
+/// the bytes of the paint copies of every surface of the object. Revolve (Classic) draws every face
+/// with its classic surface, as Extrude & Bevel (Classic) does.
#[allow(clippy::too_many_arguments)]
fn surface(
n: &Node,
@@ -275,6 +318,16 @@ fn surface(
) -> Option<(Vec, Node)> {
// A loaded file or a profile edited past the limits keeps drawing its source path instead
// of crashing the frame. Commands validate the same evaluator and report its errors.
+ if fx.id == CLASSIC_REVOLVE {
+ let facets = vectorcraft_three_d::revolve_facets(path, b, options).ok()?;
+ *copies = copies.saturating_add(paint_copies(facets.len(), &paint));
+ if *copies > MAX_PAINT_COPIES {
+ return None;
+ }
+ let depths = facets.iter().map(|f| f.depth).collect();
+ let p = merged_params(&fx.id, &fx.params);
+ return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p)));
+ }
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
*copies = copies.saturating_add(paint_copies(faces.len(), &paint));
@@ -371,8 +424,7 @@ fn surface(
fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option {
let b = path.bounds()?;
let (paint, opacity, blend) = paint_of(item);
- let options = revolve_options(&merged_params(REVOLVE, &fx.params));
- surface(n, path, b, options, fx, paint, opacity, blend, expand, copies).map(|(_, art)| art)
+ surface(n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art)
}
fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint {
@@ -429,10 +481,10 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul
let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result {
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let (paint, opacity, _) = paint_of(item);
- let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
+ let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, fx_options(fx))?;
Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) })
};
- if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
+ if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) {
let before = n.appearance.effects.get(..index).unwrap_or_default();
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
let path = geometry(&path, before, &ctx);
@@ -445,7 +497,7 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
- if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE)
+ if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id))
&& let Some(fx) = item.effects().get(index)
{
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
@@ -461,13 +513,13 @@ fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, n: &Node, expand: bool) -> Option {
let mut children = vec![];
let mut copies = 0;
let mut remaining = n.appearance.effects.clone();
- if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
+ if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) {
let fx = n.appearance.effects.get(index)?;
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
// An object's surface uses its top visible fill, falling back to its stroke.
@@ -500,7 +552,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option {
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let surface = object_surface(n, &path, fx, item, expand, &mut copies)?;
if let Some(b) = path.bounds()
- && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?
+ && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx)).ok()?
{
children.push(Arc::new(shadow));
}
@@ -511,7 +563,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option {
.effects
.iter()
.enumerate()
- .filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index))
+ .filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index))
.map(|(_, e)| e.clone())
.collect();
} else {
@@ -520,14 +572,14 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option {
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
- if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) {
+ if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) {
let fx = item.effects().get(index)?;
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?;
- art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect();
+ art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect();
if let Some(b) = p.bounds()
- && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?
+ && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx)).ok()?
{
shadow.appearance.effects = art.appearance.effects.clone();
shadows.push(Arc::new(shadow));
@@ -666,12 +718,12 @@ fn overlaps(a: Rect, b: Rect) -> bool {
/// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when
/// the effect is only on the group's own fills or strokes or when its members can't be revolved.
fn group_art(n: &Node, expand: bool) -> Option {
- let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?;
+ let index = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id))?;
let fx = n.appearance.effects.get(index)?;
let mut shapes = members(n, "").ok()?;
reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes);
let b = union_bounds(&shapes)?;
- let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
+ let options = shared_options(&shapes, fx_options(fx)).ok()?;
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let mut copies = 0;
let mut surfaces = vec![];
@@ -689,7 +741,10 @@ fn group_art(n: &Node, expand: bool) -> Option {
if surfaces.is_empty() {
return None;
}
- let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()));
+ // A classic surface whose gradient has transparent stops carries an opacity mask, and stays whole.
+ let plain = surfaces.iter().all(|(depths, s, _)| {
+ s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
+ });
let children = if !plain {
scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect(), 0)
} else {
diff --git a/crates/effects/src/revolve/classic.rs b/crates/effects/src/revolve/classic.rs
new file mode 100644
index 0000000000..c9a73ddd82
--- /dev/null
+++ b/crates/effects/src/revolve/classic.rs
@@ -0,0 +1,176 @@
+//! Revolve (Classic): the surface Revolve sweeps from a path, drawn with the classic surfaces of
+//! Extrude & Bevel (Classic) (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend
+//! steps and shading color.
+use serde_json::Value;
+use vectorcraft_three_d::Revolve;
+
+pub const CLASSIC_REVOLVE: &str = "threeD.classicRevolve";
+
+/// The numeric parameters: (key, lowest, highest, whole numbers only).
+const RANGES: [(&str, f64, f64, bool); 13] = [
+ ("angle", 0.0, 360.0, false),
+ ("offset", 0.0, 1000.0, false),
+ ("rotationX", -180.0, 180.0, false),
+ ("rotationY", -180.0, 180.0, false),
+ ("rotationZ", -180.0, 180.0, false),
+ ("perspectiveAngle", 0.0, 160.0, false),
+ ("lightRotation", -180.0, 180.0, false),
+ ("lightHeight", 0.0, 90.0, false),
+ ("lightIntensity", 0.0, 100.0, false),
+ ("ambient", 0.0, 100.0, false),
+ ("highlightIntensity", 0.0, 100.0, false),
+ ("highlightSize", 0.0, 100.0, false),
+ ("blendSteps", 1.0, 256.0, true),
+];
+
+const FLAGS: [&str; 2] = ["cap", "lightBehind"];
+
+/// The surface Revolve (Classic)'s parameters `p` sweep: Revolve's angle, cap, offset, edge,
+/// rotation and perspective, built unshaded, since the classic surfaces light its faces.
+pub(crate) fn options(p: &Value) -> Revolve {
+ Revolve { shade: false, smooth_shading: false, lights: None, light_color: [1.0; 3], ..super::revolve_options(p) }
+}
+
+/// Check Revolve (Classic)'s parameters `p` (its defaults filled in) beyond the checks it shares
+/// with Revolve: every key is one of its parameters, numbers are in range, flags are true or false,
+/// and the surface and shading color are named.
+pub(crate) fn validate(p: &Value) -> Result<(), String> {
+ crate::solids::classic::validate_classic(CLASSIC_REVOLVE, p, &RANGES, &FLAGS)
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+ use vectorcraft_doc::color::{Color, Paint};
+ use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId};
+ use vectorcraft_geom::{BezPath, PathData};
+
+ use super::*;
+
+ const BLUE: Color = Color::Rgb { r: 0.2, g: 0.4, b: 0.8 };
+
+ /// Half a vase's profile, open, filled blue.
+ fn profile() -> Node {
+ let mut b = BezPath::new();
+ b.move_to((100.0, 40.0));
+ b.line_to((130.0, 60.0));
+ b.line_to((130.0, 140.0));
+ b.line_to((100.0, 160.0));
+ Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0))
+ }
+
+ fn revolved(params: Value) -> Node {
+ let mut n = profile();
+ n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, ¶ms).unwrap());
+ crate::revolve_art(&n).unwrap()
+ }
+
+ /// The faces of the surface of `art`, back to front.
+ fn faces(art: &Node) -> Vec<&Node> {
+ art.children().unwrap().iter().flat_map(|s| s.children().unwrap().iter().map(|f| &**f)).collect()
+ }
+
+ fn fill(face: &Node) -> Option {
+ face.appearance.items.iter().find_map(|i| match i {
+ AppearanceItem::Fill(f) => f.paint.color(),
+ AppearanceItem::Stroke(_) => None,
+ })
+ }
+
+ fn facets(params: &Value) -> usize {
+ let n = profile();
+ let path = n.path_data().unwrap();
+ let p = crate::merged_params(CLASSIC_REVOLVE, params);
+ vectorcraft_three_d::revolve_facets(path, path.bounds().unwrap(), options(&p)).unwrap().len()
+ }
+
+ #[test]
+ fn plastic_shades_every_face_of_the_swept_surface_in_the_fill() {
+ let art = revolved(json!({}));
+ let drawn = faces(&art);
+ assert_eq!(drawn.len(), facets(&json!({})), "one face for each facet of the surface");
+ let colors: Vec<[f32; 3]> = drawn.iter().map(|f| fill(f).unwrap().to_rgb()).collect();
+ // Lit from one side, the faces take more than one tone, and none is another hue: a face is
+ // the blue darkened toward black, lightened toward white, or both.
+ let distinct = colors.iter().filter(|c| colors.iter().any(|d| (c[0] - d[0]).abs() > 0.02)).count();
+ assert!(distinct > 0, "{colors:?}");
+ // Diffuse has no highlight, so every face is the blue darkened toward black.
+ let art = revolved(json!({"surface": "diffuse"}));
+ for face in faces(&art) {
+ let [r, g, b] = fill(face).unwrap().to_rgb();
+ assert!(b <= 0.8 + 1e-6 && (g * 2.0 - b).abs() < 1e-4 && (r * 4.0 - b).abs() < 1e-4, "{r} {g} {b}");
+ }
+ }
+
+ #[test]
+ fn wireframe_strokes_the_edges_of_every_face_and_no_shading_keeps_the_paint() {
+ let art = revolved(json!({"surface": "wireframe"}));
+ let faces_drawn = faces(&art);
+ assert_eq!(faces_drawn.len(), facets(&json!({"surface": "wireframe"})));
+ for face in faces_drawn {
+ match face.appearance.items.as_slice() {
+ [AppearanceItem::Stroke(s)] => assert_eq!((s.paint.color(), s.width), (Some(BLUE), 0.5)),
+ items => panic!("a wireframe face is one stroke: {items:?}"),
+ }
+ }
+ for face in faces(&revolved(json!({"surface": "none"}))) {
+ assert_eq!(fill(face), Some(BLUE));
+ }
+ }
+
+ #[test]
+ fn angle_offset_edge_and_cap_shape_the_surface() {
+ let full = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).visual_bounds().unwrap();
+ // Seen from the front, the full turn is twice the profile's width across, about its left edge.
+ assert!((full.width() - 60.0).abs() < 2.0, "{full:?}");
+ let ring = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "offset": 20})).visual_bounds().unwrap();
+ assert!((ring.width() - 100.0).abs() < 2.0, "{ring:?}");
+ // About its right edge the axis is at x 130, so the surface reaches x 160.
+ let right = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "edge": "right"})).visual_bounds().unwrap();
+ assert!((right.x1 - 160.0).abs() < 2.0, "{right:?}");
+ assert!(facets(&json!({"angle": 90})) < facets(&json!({})));
+ // The profile's ends lie on the axis, so they close nothing until an offset moves them off it.
+ assert!(facets(&json!({"cap": false, "offset": 10})) < facets(&json!({"offset": 10})), "hollow leaves out the end caps");
+ }
+
+ #[test]
+ fn a_group_revolves_its_members_about_one_shared_axis() {
+ let mut b = BezPath::new();
+ b.move_to((150.0, 60.0));
+ b.line_to((170.0, 60.0));
+ b.line_to((170.0, 140.0));
+ let outer = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
+ let mut g = Node::group(NodeId(3), vec![std::sync::Arc::new(profile()), std::sync::Arc::new(outer)]);
+ g.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).unwrap());
+ let art = crate::revolve_art(&g).unwrap();
+ // The axis is at the left edge of both members together, x 100, so the outer one reaches 70
+ // out on either side of it.
+ let bounds = art.visual_bounds().unwrap();
+ assert!((bounds.x0 - 30.0).abs() < 2.0 && (bounds.x1 - 170.0).abs() < 2.0, "{bounds:?}");
+ let mut colors = vec![];
+ art.walk(&mut |n| colors.extend(fill(n).map(|c| c.to_rgb())));
+ assert!(colors.iter().any(|c| c[2] > c[0]) && colors.iter().any(|c| c[0] == c[2]), "both members' paints are drawn");
+ }
+
+ #[test]
+ fn bad_parameters_are_refused() {
+ let check = |p: Value| crate::validate_three_d(&profile(), CLASSIC_REVOLVE, &crate::merged_params(CLASSIC_REVOLVE, &p));
+ assert_eq!(check(json!({})), Ok(()));
+ for (p, error) in [
+ (json!({"segments": 32}), "unknown parameter `segments`"),
+ (json!({"angle": 400}), "`angle` must be between 0 and 360"),
+ (json!({"offset": 1001}), "`offset` must be between 0 and 1000"),
+ (json!({"rotationY": -181}), "`rotationY` must be between -180 and 180"),
+ (json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number"),
+ (json!({"lightBehind": 1}), "`lightBehind` must be true or false"),
+ (json!({"surface": "glossy"}), "`surface` must be one of"),
+ (json!({"shadingColor": "white"}), "`shadingColor` must be one of"),
+ (json!({"shadingCustom": "red"}), "`shadingCustom` must be a #rrggbb color"),
+ (json!({"edge": "middle"}), "Revolve edge must be left or right"),
+ (json!({"cap": "yes"}), "Revolve cap must be a boolean"),
+ ] {
+ let e = check(p.clone()).unwrap_err();
+ assert!(e.contains(error), "{p}: {e}");
+ }
+ }
+}
diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs
index ff5b1c9711..8d9ff9eea8 100644
--- a/crates/effects/src/solids.rs
+++ b/crates/effects/src/solids.rs
@@ -1,4 +1,4 @@
-//! Extrude & Bevel, Inflate, Rotate and Extrude & Bevel (Classic) as live, shaded vector art,
+//! Extrude & Bevel, Inflate, Rotate, Extrude & Bevel (Classic) and Rotate (Classic) as live, shaded vector art,
//! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
use std::sync::Arc;
@@ -14,8 +14,8 @@ use vectorcraft_doc::color::Color;
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry};
-mod classic;
-pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading};
+pub(crate) mod classic;
+pub use classic::{CLASSIC_EXTRUDE, CLASSIC_ROTATE, ROTATE_SURFACES, SHADING_COLORS, classic_extrude_options, classic_shading};
mod source;
pub(crate) use source::{resolve, resolve_for};
@@ -26,11 +26,22 @@ pub const EXTRUDE: &str = "threeD.extrude";
pub const INFLATE: &str = "threeD.inflate";
pub const ROTATE: &str = "threeD.rotate";
/// The 3D effects this module draws (Revolve has its own).
-pub const SOLIDS: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE];
+pub const SOLIDS: [&str; 5] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE, CLASSIC_ROTATE];
-/// Is `id` one of the 3D and Materials effects (Revolve included)?
+/// Is `id` one of the 3D and Materials effects (Revolve and Revolve (Classic) included)?
pub fn is_three_d(id: &str) -> bool {
- id == crate::REVOLVE || SOLIDS.contains(&id)
+ crate::revolve::is_revolve(id) || SOLIDS.contains(&id)
+}
+
+/// Is `id` one of the 3D (Classic) effects: Extrude & Bevel (Classic), Revolve (Classic) or Rotate
+/// (Classic)?
+pub fn is_classic_three_d(id: &str) -> bool {
+ id == CLASSIC_EXTRUDE || id == crate::CLASSIC_REVOLVE || id == CLASSIC_ROTATE
+}
+
+/// Does 3D effect `id` turn the art as a flat plane: Rotate or Rotate (Classic)?
+fn is_plane(id: &str) -> bool {
+ id == ROTATE || id == CLASSIC_ROTATE
}
/// The 3D and Materials panel's object types and the effect that draws each: (type, effect id).
@@ -193,6 +204,9 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> {
if id == CLASSIC_EXTRUDE {
classic::validate(p)?;
}
+ if id == CLASSIC_ROTATE {
+ classic::validate_rotate(p)?;
+ }
crate::lighting::validate(p)?;
shadow::validate(p)?;
if let Some(v) = p.get("bevel")
@@ -225,7 +239,7 @@ pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value)
fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option) -> Result<(), String> {
validate_solid_params(id, p)?;
let label = label_of(id);
- let n = &*source::resolve_for(doc, n, label, id == ROTATE)?;
+ let n = &*source::resolve_for(doc, n, label, is_plane(id))?;
if is_container(n) {
let shapes = members(n, label)?;
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
@@ -417,7 +431,7 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> {
/// [`validate_three_d`] with the symbols of `doc` ([`validate_solid_in`]).
pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> {
- if id == crate::REVOLVE { crate::revolve::validate_revolve_in(doc, n, p) } else { validate_solid_in(doc, n, id, p) }
+ if crate::revolve::is_revolve(id) { crate::revolve::validate_in(doc, n, id, p) } else { validate_solid_in(doc, n, id, p) }
}
/// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of
@@ -436,7 +450,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va
Some(_) if is_container(n) || source::resolves(n) => {
Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into())
}
- Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index),
+ Some((item, index)) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index),
Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)),
None => validate_three_d_in(doc, n, id, p),
}
@@ -446,7 +460,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va
/// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does
/// not need to fit.
pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> {
- if id == crate::REVOLVE { crate::revolve::validate_revolve_params(p) } else { validate_solid_params(id, p) }
+ if crate::revolve::is_revolve(id) { crate::revolve::validate_params_of(id, p) } else { validate_solid_params(id, p) }
}
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
@@ -708,12 +722,12 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option(id: &str, p: &Value, shapes: &'a [Profile]) -> Result>, String> {
- if id == CLASSIC_EXTRUDE {
+ if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE {
return Ok(None);
}
let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) };
@@ -775,12 +789,12 @@ type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow,
fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> {
let b = union_bounds(shapes)?;
let p = crate::merged_params(&fx.id, &fx.params);
- let classic = fx.id == CLASSIC_EXTRUDE;
+ let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE;
let colored = !classic && view_options(&p).has_colored_lights();
let masks = has_masks(shapes);
// The paint copies of every solid together: past the allowance the source path is drawn.
let mut paint_bytes = 0usize;
- if fx.id == ROTATE {
+ if is_plane(&fx.id) {
// Every part turns about the centre of the object's own bounds (a group's members about
// the centre of theirs), so they stay together.
let mut out = vec![];
@@ -792,7 +806,8 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, limit);
- if fx.id == ROTATE {
+ if is_plane(&fx.id) {
for s in shapes {
for item in &s.items {
let (paint, opacity, _) = paint_of(item, s.opacity);
diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs
index 730fb8e53b..919de831ce 100644
--- a/crates/effects/src/solids/classic.rs
+++ b/crates/effects/src/solids/classic.rs
@@ -1,6 +1,6 @@
//! Extrude & Bevel (Classic): the solid Extrude & Bevel builds from a path, drawn with the classic
//! surfaces (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend steps and shading
-//! color.
+//! color. Rotate (Classic): the plane Rotate turns, drawn with the No Shading and Diffuse surfaces.
use std::sync::Arc;
use serde_json::Value;
@@ -12,6 +12,10 @@ use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Ext
use crate::util::{flag, num, text};
pub const CLASSIC_EXTRUDE: &str = "threeD.classicExtrude";
+pub const CLASSIC_ROTATE: &str = "threeD.classicRotate";
+
+/// The surfaces Rotate (Classic) takes: No Shading and Diffuse.
+pub const ROTATE_SURFACES: [&str; 2] = ["none", "diffuse"];
/// The width of a Wireframe edge, in points.
const WIRE: f64 = 0.5;
@@ -104,8 +108,39 @@ const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"
/// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and
/// `shadingCustom` name a surface, a shading color and a #rrggbb color.
pub fn validate(p: &Value) -> Result<(), String> {
+ validate_classic(CLASSIC_EXTRUDE, p, &RANGES, &FLAGS)
+}
+
+/// Rotate (Classic)'s numeric parameters: (key, lowest, highest, whole numbers only).
+const ROTATE_RANGES: [(&str, f64, f64, bool); 9] = [
+ ("rotationX", -180.0, 180.0, false),
+ ("rotationY", -180.0, 180.0, false),
+ ("rotationZ", -180.0, 180.0, false),
+ ("perspectiveAngle", 0.0, 160.0, false),
+ ("lightRotation", -180.0, 180.0, false),
+ ("lightHeight", 0.0, 90.0, false),
+ ("lightIntensity", 0.0, 100.0, false),
+ ("ambient", 0.0, 100.0, false),
+ ("blendSteps", 1.0, 256.0, true),
+];
+
+/// Check Rotate (Classic)'s parameters `p` (its defaults filled in) as [`validate`] checks Extrude &
+/// Bevel (Classic)'s, with `surface` No Shading or Diffuse only.
+pub fn validate_rotate(p: &Value) -> Result<(), String> {
+ validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind"])?;
+ if p.get("surface").is_some_and(|v| !v.as_str().is_some_and(|s| ROTATE_SURFACES.contains(&s))) {
+ return Err(format!("`surface` must be one of {}", ROTATE_SURFACES.join(", ")));
+ }
+ Ok(())
+}
+
+/// Check the parameters `p` of classic 3D effect `id` (its defaults filled in): every key is one of
+/// its parameters, the numbers `ranges` lists, as (key, lowest, highest, whole numbers only), are in
+/// range, the `flags` are true or false, and `surface`, `shadingColor` and `shadingCustom` name a
+/// surface, a shading color and a #rrggbb color.
+pub(crate) fn validate_classic(id: &str, p: &Value, ranges: &[(&str, f64, f64, bool)], flags: &[&str]) -> Result<(), String> {
let Some(map) = p.as_object() else { return Ok(()) };
- let defaults = crate::default_params(CLASSIC_EXTRUDE).unwrap_or(Value::Null);
+ let defaults = crate::default_params(id).unwrap_or(Value::Null);
let known: Vec<&str> = defaults.as_object().map(|m| m.keys().map(String::as_str).collect()).unwrap_or_default();
for key in map.keys() {
if !known.contains(&key.as_str()) {
@@ -113,7 +148,7 @@ pub fn validate(p: &Value) -> Result<(), String> {
return Err(format!("unknown parameter `{key}`; the command takes: {keys}"));
}
}
- for (key, min, max, whole) in RANGES {
+ for &(key, min, max, whole) in ranges {
let Some(v) = map.get(key) else { continue };
let Some(x) = v.as_f64() else { return Err(format!("`{key}` must be a number")) };
if whole && (x.fract() != 0.0 || !(min..=max).contains(&x)) {
@@ -123,7 +158,7 @@ pub fn validate(p: &Value) -> Result<(), String> {
return Err(format!("`{key}` must be between {min} and {max} (got {x})"));
}
}
- for key in FLAGS {
+ for &key in flags {
if map.get(key).is_some_and(|v| !v.is_boolean()) {
return Err(format!("`{key}` must be true or false"));
}
@@ -161,7 +196,7 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option) -> Color {
/// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by
/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by
/// its own transparency, so the shading follows the gradient's coverage.
-pub(super) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node {
+pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node {
let shading = classic_shading(p);
let lights = lights(p);
let shade = shading_color(p);
diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs
index 66ca2c1d72..585f23bd6b 100644
--- a/crates/effects/src/solids/source.rs
+++ b/crates/effects/src/solids/source.rs
@@ -55,7 +55,7 @@ fn needs(n: &Node) -> bool {
/// a clipping path too complex to cut the art with. `label` is the effect's name. The art is cut
/// for the solid effect `n` carries ([`resolve_for`]).
pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result, String> {
- let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE);
+ let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| super::is_plane(&fx.id));
resolve_for(doc, n, label, every_fill)
}
diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs
index 248473f428..c960bbe671 100644
--- a/crates/effects/src/solids/tests.rs
+++ b/crates/effects/src/solids/tests.rs
@@ -889,3 +889,80 @@ fn classic_parameters_are_checked() {
assert!(err.starts_with(says), "{p}: {err}");
}
}
+
+/// The red square, stroked black, with Rotate (Classic) and `params`: its surfaces, the fill's
+/// plane first, then the stroke's outline.
+fn classic_rotate(params: Value) -> Vec {
+ let mut n = square_node();
+ n.appearance.effects.push(fx(CLASSIC_ROTATE, params));
+ solid_art(&n).unwrap().children().unwrap().iter().map(|s| (**s).clone()).collect()
+}
+
+#[test]
+fn classic_rotate_turns_every_fill_and_stroke_as_one_plane() {
+ let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0});
+ let surfaces = classic_rotate(front.clone());
+ assert_eq!(surfaces.len(), 2, "the fill's plane and the stroke's outline");
+ assert!(surfaces.iter().all(|s| s.children().unwrap().len() == 1), "each is one flat face");
+ let fill = surfaces[0].visual_bounds().unwrap();
+ assert!((fill.width() - 100.0).abs() < 1.0 && (fill.height() - 100.0).abs() < 1.0, "{fill:?}");
+ // Turned 60° about the vertical axis, the square shows half its width and all its height,
+ // about the center of its bounds.
+ let turned = classic_rotate(json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0}))[0].visual_bounds().unwrap();
+ assert!((turned.width() - 50.0).abs() < 1.0 && (turned.height() - 100.0).abs() < 1.0, "{turned:?}");
+ assert!((turned.center().x - 150.0).abs() < 1.0, "{turned:?}");
+ // Turned past 90°, the back of the plane is drawn.
+ let back = classic_rotate(json!({"rotationX": 0, "rotationY": 150, "rotationZ": 0}));
+ assert!(back[0].visual_bounds().is_some_and(|b| (b.width() - 100.0 * 30f64.to_radians().cos()).abs() < 1.0));
+}
+
+#[test]
+fn classic_rotate_shades_with_no_shading_or_diffuse() {
+ let rgb = |p: Value| fill_rgb(&classic_rotate(p)[0].children().unwrap()[0]);
+ // No Shading keeps the paint, the color itself kept.
+ let mut n = square_node();
+ n.appearance.effects.push(fx(CLASSIC_ROTATE, json!({"surface": "none"})));
+ let art = solid_art(&n).unwrap();
+ let AppearanceItem::Fill(f) = &faces(&art)[0].appearance.items[0] else { panic!("a fill first") };
+ assert_eq!(f.paint, Paint::solid(RED));
+ // Diffuse: straight on with the light in front, the face is fully lit; turned away from the
+ // light, it is the red darkened toward black.
+ let lit = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "lightHeight": 90});
+ assert!(close3(rgb(lit), RED.to_rgb()));
+ let [r, g, b] = rgb(json!({"rotationX": 0, "rotationY": 70, "rotationZ": 0, "lightHeight": 90}));
+ assert!(r < 0.8 - 0.05 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4, "{r} {g} {b}");
+ // No light: the ambient share of the red. A custom shading color darkens toward that color;
+ // None keeps the paint.
+ let dark = rgb(json!({"lightIntensity": 0, "ambient": 20, "blendSteps": 256}));
+ assert!((dark[0] - 0.8 * 0.2).abs() < 0.01, "{dark:?}");
+ let blue = rgb(json!({"lightIntensity": 0, "ambient": 20, "shadingColor": "custom", "shadingCustom": "#0000ff"}));
+ assert!(blue[2] > blue[0] / 8.0 + 0.01, "{blue:?}");
+ assert!(close3(rgb(json!({"lightIntensity": 0, "shadingColor": "none"})), RED.to_rgb()));
+}
+
+#[test]
+fn classic_rotate_parameters_are_checked() {
+ let n = square_node();
+ let check = |p: Value| validate_solid(&n, CLASSIC_ROTATE, &crate::merged_params(CLASSIC_ROTATE, &p));
+ assert!(check(json!({})).is_ok());
+ assert!(check(json!({"surface": "none", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 1})).is_ok());
+ for (p, says) in [
+ (json!({"surface": "plastic"}), "`surface` must be one of none, diffuse"),
+ (json!({"surface": "wireframe"}), "`surface` must be one of none, diffuse"),
+ (json!({"surface": 2}), "`surface` must be one of"),
+ (json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"),
+ (json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"),
+ (json!({"rotationX": 181}), "`rotationX` must be between -180 and 180 (got 181)"),
+ (json!({"perspectiveAngle": 161}), "`perspectiveAngle` must be between 0 and 160 (got 161)"),
+ (json!({"lightIntensity": 101}), "`lightIntensity` must be between 0 and 100 (got 101)"),
+ (json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"),
+ (json!({"lightBehind": 1}), "`lightBehind` must be true or false"),
+ (json!({"highlightSize": 50}), "unknown parameter `highlightSize`"),
+ (json!({"drawHiddenFaces": true}), "unknown parameter `drawHiddenFaces`"),
+ (json!({"shadow": true}), "unknown parameter `shadow`"),
+ (json!({"lights": []}), "lights must contain one to ten lights"),
+ ] {
+ let err = check(p.clone()).unwrap_err();
+ assert!(err.starts_with(says), "{p}: {err}");
+ }
+}
diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs
index b8c1f82e50..465025302f 100644
--- a/crates/engine/src/cmd.rs
+++ b/crates/engine/src/cmd.rs
@@ -507,6 +507,23 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result Ok(Some(vec![checked_id(s, v, command)?])),
}
}
+/// The objects `p` gives `command`, as [`given_objects`] reads them, checked only for their form:
+/// for a command that looks its objects up itself, outside the document's layers too.
+pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result>> {
+ if p.get("ids").is_some_and(|v| !v.is_null()) {
+ return ids_param(p, "ids", command);
+ }
+ match p.get("id").filter(|v| !v.is_null()) {
+ None => Ok(None),
+ Some(Value::String(r)) if r.starts_with('$') => {
+ Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
+ }
+ Some(v) => {
+ let id = v.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {v}: expected a non-negative integer")))?;
+ Ok(Some(vec![id]))
+ }
+ }
+}
/// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list.
pub(crate) fn index(v: u64) -> usize {
usize::try_from(v).unwrap_or(usize::MAX)
diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs
index bc1d4e4f25..537e6bfe75 100644
--- a/crates/engine/src/cmd/addons/typetools.rs
+++ b/crates/engine/src/cmd/addons/typetools.rs
@@ -21,7 +21,7 @@ use super::super::menucmds::whole_or;
use super::super::object::choice;
use super::super::textedit::{CharChange, text_mut, text_ref, type_ranges};
use super::super::typecmd::refresh_bounds;
-use super::super::typemenu::text_ids;
+use super::super::typemenu::{bake_dynamic_text, text_ids};
use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or, targets};
use crate::{EngineError, Result, Session};
@@ -61,7 +61,7 @@ pub fn specs() -> Vec {
"Release Type on a Path…",
["Object", "Plug-ins", "Type Tools"],
None,
- "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
+ "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
has_selection,
release_path
),
@@ -230,8 +230,9 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option {
/// `t`, type on a path, as point type on one straight line: the same runs and character and
/// paragraph attributes, its baseline starting where its first character sat on the path (in the
-/// object's own text space, so its transform stays). Type on a Path Options are dropped. None for
-/// other type, or when it can't be placed.
+/// object's own text space, so its transform stays). Type on a Path Options are dropped, and
+/// Dynamic Text is turned off at the sizes it draws the text at. None for other type, or when it
+/// can't be placed.
fn released_text(t: &TextObject) -> Option {
let TextKind::OnPath { path, .. } = &t.kind else { return None };
let db = vectorcraft_text::FontDb::global();
@@ -244,6 +245,8 @@ fn released_text(t: &TextObject) -> Option {
(p.x.is_finite() && p.y.is_finite()).then_some(p)
})?;
let mut out = t.clone();
+ // Point type has no brackets to fit: it keeps the sizes the path drew it at.
+ bake_dynamic_text(&mut out);
out.kind = TextKind::Point;
out.path_effect = Default::default();
out.path_align = Default::default();
diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs
index b4b231a22b..59a54eca5f 100644
--- a/crates/engine/src/cmd/brushsym.rs
+++ b/crates/engine/src/cmd/brushsym.rs
@@ -33,8 +33,8 @@ mod symbols;
pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint};
use brushes::{
- brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove,
- brush_selection_art, brush_set_current, brush_unused, expand_brush,
+ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_place_art,
+ brush_remove, brush_replace_art, brush_selection_art, brush_set_current, brush_unused, expand_brush,
};
pub(crate) use dynamic::{edit_part, part_only, selected_part};
use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance};
@@ -67,6 +67,24 @@ pub fn specs() -> Vec {
has_doc,
brush_selection_art
),
+ cmd!(
+ "brush.placeArt",
+ "Place Brush Art",
+ [],
+ None,
+ "{name, x?, y?} place a copy of the art a scatter, art or pattern brush is made of, centered on (x, y) (default: the center of the first artboard), and select it, as dragging the brush from the Brushes panel onto an empty part of the canvas does; a pattern brush places its tiles left to right (side, outer corner, inner corner, start, end; the ones it has art for) 10 pt apart, each in a group named Side Tile, Outer Corner Tile, Inner Corner Tile, Start Tile or End Tile, with an unpainted rectangle at the back of a tile that has a repeat frame marking that frame; a calligraphic or bristle brush is refused → {id}",
+ has_doc,
+ brush_place_art
+ ),
+ cmd!(
+ "brush.replaceArt",
+ "Replace Brush Art",
+ [],
+ None,
+ "{name, ids?|id?, applyToStrokes?: true} make the selected art (several objects become one group, as in brush.new) the art of a scatter, art or pattern brush, keeping its other options, as Alt-dragging (Option-dragging on macOS) art onto the brush in the Brushes panel does; a pattern brush takes a group named for one of its tiles, or a group of such groups (as brush.placeArt places them), tile by tile (of two named for one tile, the frontmost), each with the repeat frame an unpainted rectangle at its back marks (other tiles stay as they are), and other art as its side tile; when strokes use the brush and applyToStrokes is false, the brush and its strokes stay as they were and a copy of the brush with the new art, named ` copy`, goes after it and becomes the current brush; otherwise the strokes redraw with the new art; one undo step; refused for a calligraphic or bristle brush, with nothing selected, and for art (or a tile) with no width → {name: the brush that has the new art}",
+ has_doc,
+ brush_replace_art
+ ),
cmd!(
"brush.apply",
"Apply Brush",
diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs
index cf5dedb6bd..1f63076cfd 100644
--- a/crates/engine/src/cmd/brushsym/brushes.rs
+++ b/crates/engine/src/cmd/brushsym/brushes.rs
@@ -239,7 +239,7 @@ pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result {
&& v.get(k).is_none()
{
let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?;
- if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) {
+ if !has_width(&art, &s.doc()?.doc.symbol_boxes()) {
return Err(bad(C, "the selected art has no width"));
}
v[k] = serde_json::to_value(&art).unwrap_or_default();
@@ -249,6 +249,12 @@ pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result {
Ok(json!({ "name": name }))
}
+/// Whether art `n` has width for a brush to be made of, with symbol instances measured by their
+/// symbols' art in `symbols`.
+fn has_width(n: &Node, symbols: &vectorcraft_doc::SymbolBoxes) -> bool {
+ n.geometric_bounds_with(symbols).is_some_and(|b| b.width() > 1e-6)
+}
+
/// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters.
fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> {
let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS);
@@ -367,6 +373,12 @@ fn art_brushes(n: &Node, out: &mut Vec) {
pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result {
let d = &s.doc()?.doc;
let lib = brush::library(d);
+ let names = brush_unused_in(d, &lib);
+ Ok(json!({ "names": names }))
+}
+
+/// The brushes of `lib` no stroke in `d` paints with, in library order ([`brush_unused`]).
+fn brush_unused_in(d: &Document, lib: &[Brush]) -> Vec {
let mut todo = vec![];
let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art));
let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()));
@@ -379,8 +391,7 @@ pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result {
used.insert(name);
}
}
- let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect();
- Ok(json!({ "names": names }))
+ lib.iter().map(|b| b.name.clone()).filter(|n| !used.contains(n)).collect()
}
pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result {
@@ -483,6 +494,217 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result {
Ok(json!({ "name": out }))
}
+/// The name `brush.placeArt` gives each pattern brush tile it places, by tile, in the order it
+/// places them; `brush.replaceArt` reads a group of that name as that tile.
+const TILE_NAMES: [&str; 5] = ["Side Tile", "Outer Corner Tile", "Inner Corner Tile", "Start Tile", "End Tile"];
+
+/// The space between the pattern brush tiles `brush.placeArt` places, in points.
+const TILE_GAP: f64 = 10.0;
+
+/// The message for a brush that is not made of art.
+fn no_art(name: &str) -> String {
+ format!("`{name}` has no art (only scatter, art and pattern brushes do)")
+}
+
+/// Tile `i` of `p` ([`TILE_NAMES`] order): its art and its repeat frame, when it has art.
+fn tile(p: &brush::PatternBrush, i: usize) -> Option<(&Node, Option)> {
+ match i {
+ 0 => Some((&p.side, p.side_frame)),
+ 1 => p.outer_corner.as_ref().map(|n| (n, p.outer_corner_frame)),
+ 2 => p.inner_corner.as_ref().map(|n| (n, p.inner_corner_frame)),
+ 3 => p.start.as_ref().map(|n| (n, p.start_frame)),
+ _ => p.end.as_ref().map(|n| (n, p.end_frame)),
+ }
+}
+
+/// Gives tile `i` of `p` ([`TILE_NAMES`] order) the art `art` and the repeat frame `frame`. The tile
+/// no longer comes from a pattern swatch, and a corner draws its own art.
+fn set_tile(p: &mut brush::PatternBrush, i: usize, art: Node, frame: Option) {
+ match i {
+ 0 => (p.side, p.side_frame, p.side_swatch) = (art, frame, None),
+ 1 => (p.outer_corner, p.outer_corner_frame, p.outer_corner_swatch, p.outer_source) = (Some(art), frame, None, None),
+ 2 => (p.inner_corner, p.inner_corner_frame, p.inner_corner_swatch, p.inner_source) = (Some(art), frame, None, None),
+ 3 => (p.start, p.start_frame, p.start_swatch) = (Some(art), frame, None),
+ _ => (p.end, p.end_frame, p.end_swatch) = (Some(art), frame, None),
+ }
+}
+
+/// The art `brush.placeArt` places for brush `b`, in the brush's own coordinates: a scatter or art
+/// brush's art, or a pattern brush's tiles in a row, each in a group named for its tile
+/// ([`TILE_NAMES`]), with an unpainted rectangle at the back of a tile that has a repeat frame
+/// marking that frame. `None` for a brush that is not made of art. Symbol instances in the art are
+/// measured by their symbols' art in `symbols`.
+fn placed_art(b: &Brush, symbols: &vectorcraft_doc::SymbolBoxes) -> Option {
+ match &b.kind {
+ BrushKind::Scatter(sc) => Some(sc.art.clone()),
+ BrushKind::Art(a) => Some(a.art.clone()),
+ BrushKind::Pattern(pb) => {
+ let mut tiles = vec![];
+ let mut x = 0.0;
+ for (i, tile_name) in TILE_NAMES.iter().enumerate() {
+ let Some((art, frame)) = tile(pb, i) else { continue };
+ let mut children = vec![];
+ if let Some(f) = frame {
+ let marker = Node::path(
+ NodeId(0),
+ vectorcraft_geom::shapes::rectangle(f),
+ vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0),
+ );
+ children.push(Arc::new(marker));
+ }
+ children.push(Arc::new(art.clone()));
+ let mut g = Node::group(NodeId(0), children);
+ g.name = Some((*tile_name).to_string());
+ let Some(r) = g.geometric_bounds_with(symbols) else { continue };
+ // Each tile sits right of the one before it, centered on the line y = 0.
+ g.transform(vectorcraft_geom::Affine::translate((x - r.x0, -r.center().y)), false);
+ x += r.width() + TILE_GAP;
+ tiles.push(Arc::new(g));
+ }
+ Some(Node::group(NodeId(0), tiles))
+ }
+ BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => None,
+ }
+}
+
+/// `brush.placeArt`: a copy of the art brush `name` is made of, centered on (`x`, `y`) (default: the
+/// center of the first artboard) in the layer new art goes into, and selected, as one undo step.
+/// Scatter and art brushes place their art; pattern brushes place their tiles ([`placed_art`]).
+pub(super) fn brush_place_art(s: &mut Session, p: &Value) -> Result {
+ const C: &str = "brush.placeArt";
+ let name = name_param(p, C)?.to_string();
+ let d = &s.doc()?.doc;
+ let center = d.artboards.first().map(|a| a.rect.center()).unwrap_or(super::Point::ZERO);
+ let at = super::Point::new(super::number_or(p, "x", center.x, C)?, super::number_or(p, "y", center.y, C)?);
+ let b = brush::find(d, &name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
+ let symbols = d.symbol_boxes();
+ let mut art = placed_art(&b, &symbols).ok_or_else(|| bad(C, no_art(&name)))?;
+ if let Some(r) = art.geometric_bounds_with(&symbols) {
+ art.transform(vectorcraft_geom::Affine::translate(at - r.center()), false);
+ }
+ let parent = s.doc()?.target_parent()?;
+ let id = s.edit("Place Brush Art", |d, sel| {
+ let art = d.reid(&art);
+ let id = art.id;
+ d.insert(parent, usize::MAX, art)?;
+ sel.set([id]);
+ Ok(id)
+ })?;
+ Ok(json!({ "id": id.0 }))
+}
+
+/// The repeat frame an unpainted rectangle at the back of a tile group marks ([`placed_art`]): the
+/// rectangle's bounds, when `n` is a path of one closed subpath through the four corners of its
+/// bounds, without curves, whose fills and strokes paint nothing and that has no other appearance.
+fn frame_marker(n: &Node) -> Option {
+ let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind else { return None };
+ let unpainted = n.appearance.effects.is_empty()
+ && n.appearance.items.iter().all(|i| match i {
+ AppearanceItem::Fill(f) => f.paint.is_none(),
+ AppearanceItem::Stroke(st) => st.paint.is_none(),
+ });
+ let [sp] = path.subpaths.as_slice() else { return None };
+ let r = n.geometric_bounds()?;
+ let corners = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)];
+ let at_corners = sp.anchors.len() == 4
+ && sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p)
+ && corners.iter().all(|c| sp.anchors.iter().any(|a| (a.p.x, a.p.y) == *c));
+ (unpainted && sp.closed && at_corners && r.width() > 1e-9 && r.height() > 1e-9).then_some(r)
+}
+
+/// The tiles in `art` for a pattern brush, by tile ([`TILE_NAMES`] order), with their repeat frames:
+/// a group named for a tile, as `brush.placeArt` places them, is the art of its tile (an unpainted
+/// rectangle at its back marks the tile's frame, [`frame_marker`], and is left out of the art; a
+/// group left with one member gives that member), and so is each member of a group whose members
+/// are all such groups; any other art is the side tile.
+fn tiles_of(art: Node) -> Vec<(usize, Node, Option)> {
+ let named = |c: &Node| matches!(c.kind, NodeKind::Group { clip: false, .. }) && c.name.as_deref().is_some_and(|n| TILE_NAMES.contains(&n));
+ let tiles = match &art.kind {
+ _ if named(&art) => vec![Arc::new(art)],
+ NodeKind::Group { children, clip: false } if !children.is_empty() && children.iter().all(|c| named(c)) => children.clone(),
+ _ => return vec![(0, art, None)],
+ };
+ let mut out: Vec<(usize, Node, Option)> = vec![];
+ for t in tiles {
+ let Some(i) = t.name.as_deref().and_then(|n| TILE_NAMES.iter().position(|x| *x == n)) else { continue };
+ let mut g = (*t).clone();
+ let mut frame = None;
+ if let NodeKind::Group { children, .. } = &mut g.kind
+ && let Some(f) = children.first().and_then(|c| frame_marker(c))
+ {
+ frame = Some(f);
+ children.remove(0);
+ }
+ g.name = None;
+ // The group `brush.placeArt` put around the tile's art: the art itself goes back, so a
+ // tile does not gain a group each time it goes out and back.
+ if let NodeKind::Group { children, clip: false } = &g.kind
+ && let [only] = children.as_slice()
+ && g == Node::group(g.id, children.clone())
+ {
+ g = (**only).clone();
+ }
+ // A tile named twice: the frontmost one counts.
+ out.retain(|(j, ..)| *j != i);
+ out.push((i, g, frame));
+ }
+ out
+}
+
+/// `brush.replaceArt`: the selected art (or `ids`/`id`; several objects become one group, as in
+/// `brush.new`) becomes the art of scatter, art or pattern brush `name`, keeping its other options,
+/// as one undo step. A pattern brush takes the tiles `brush.placeArt` places by their names
+/// ([`tiles_of`]) and other art as its side tile. When strokes use the brush and `applyToStrokes` is
+/// false, the brush and its strokes stay as they were and a copy of the brush with the new art is
+/// added after it and becomes the current brush; otherwise the strokes redraw with the new art.
+pub(super) fn brush_replace_art(s: &mut Session, p: &Value) -> Result {
+ const C: &str = "brush.replaceArt";
+ let name = name_param(p, C)?.to_string();
+ let apply = super::switch_or(p, "applyToStrokes", true, C)?;
+ let mut lib = brush::library(&s.doc()?.doc);
+ let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
+ if lib[i].art().is_empty() {
+ return Err(bad(C, no_art(&name)));
+ }
+ let art = selection_art(s, p)?.ok_or_else(|| bad(C, "nothing selected"))?;
+ let d = &s.doc()?.doc;
+ let symbols = d.symbol_boxes();
+ let check = |n: &Node| if has_width(n, &symbols) { Ok(()) } else { Err(bad(C, "the selected art has no width")) };
+ let mut b = lib[i].clone();
+ match &mut b.kind {
+ BrushKind::Scatter(sc) => {
+ check(&art)?;
+ sc.art = art;
+ }
+ BrushKind::Art(a) => {
+ check(&art)?;
+ a.art = art;
+ }
+ BrushKind::Pattern(pb) => {
+ for (t, art, frame) in tiles_of(art) {
+ check(&art)?;
+ set_tile(pb, t, art, frame);
+ }
+ }
+ BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => return Err(bad(C, no_art(&name))),
+ }
+ let used = !brush_unused_in(d, &lib).contains(&name);
+ if apply || !used {
+ lib[i] = b;
+ save_library(s, "Replace Brush Art", lib, &[])?;
+ return Ok(json!({ "name": name }));
+ }
+ b.name = brush::unique_name(&lib, &format!("{} copy", name.trim()));
+ let copy = b.name.clone();
+ lib.insert(i + 1, b);
+ s.edit("Replace Brush Art", |d, _| {
+ d.unknown.insert(brush::CURRENT_KEY.into(), json!(copy));
+ brush::store(d, &lib);
+ Ok(())
+ })?;
+ Ok(json!({ "name": copy }))
+}
+
pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result {
let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?);
let ids: Vec = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect();
diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs
index 7499e14943..ff1fbe3591 100644
--- a/crates/engine/src/cmd/docinfo.rs
+++ b/crates/engine/src/cmd/docinfo.rs
@@ -1,7 +1,7 @@
//! Document Info panel data, its categories and the text report (Document Info › Save…, File →
//! Package), and Object → Make Pixel Perfect.
-use std::collections::{BTreeMap, BTreeSet, HashSet};
+use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use serde_json::{Value, json};
use vectorcraft_color::Paint;
@@ -18,7 +18,7 @@ pub fn specs() -> Vec {
"Document Info",
["Window", "Document Info"],
None,
- "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}",
+ "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone",
has_doc,
info
),
@@ -358,6 +358,16 @@ fn info(s: &mut Session, p: &Value) -> Result {
let mut fonts = BTreeSet::new();
let mut images: BTreeMap = BTreeMap::new();
let mut image_rows: Vec = vec![];
+ // Whether the request being served may read each linked file (a relative path read from the
+ // document's folder), checked once per file.
+ let mut readable: HashMap = HashMap::new();
+ let link_doc = super::links::LinkBase::of(st).doc;
+ let mut may_read = |stored: &str| {
+ *readable.entry(stored.to_string()).or_insert_with(|| {
+ let resolved = super::fileio::document_path(stored, link_doc).ok();
+ crate::file_access::check_read(resolved.as_deref().unwrap_or(stored)).is_ok()
+ })
+ };
let mut patterns = BTreeSet::new();
let mut symbols = BTreeSet::new();
let mut spot = BTreeSet::new();
@@ -411,12 +421,15 @@ fn info(s: &mut Session, p: &Value) -> Result {
Some("textObjects")
}
NodeKind::Image(im) => {
+ // A linked file outside a confined request's read roots: `link` is null, and the row
+ // names the file alone.
+ let link = im.link.as_ref().map(|l| (l, may_read(&l.path)));
images.insert(
im.key.clone(),
- json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }),
+ json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": link.and_then(|(l, ok)| ok.then_some(&l.path)) }),
);
let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string());
- image_rows.push((name, im.width, im.height, im.link.as_ref().map(|l| l.path.clone())));
+ image_rows.push((name, im.width, im.height, link.map(|(l, ok)| if ok { l.path.clone() } else { l.name().to_string() })));
Some("images")
}
NodeKind::SymbolInstance { symbol, .. } => {
diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs
index 30d927a966..b570ce332a 100644
--- a/crates/engine/src/cmd/effectcmd.rs
+++ b/crates/engine/src/cmd/effectcmd.rs
@@ -78,7 +78,7 @@ pub fn specs() -> Vec {
"3D Lighting Preset",
[],
None,
- "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), which has no lighting presets; one undo step → {ids}",
+ "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}",
has_doc,
lighting_preset
),
@@ -123,7 +123,7 @@ pub fn specs() -> Vec {
"Refresh Deferred Effects",
["Effect"],
None,
- "{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
+ "{ids?|id?: [object id] (objects of the document, also in the art of a symbol, pattern, brush or opacity mask; an object without a deferred stage adds nothing; an id of no object is an error; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
has_deferred,
refresh_deferred
),
@@ -150,16 +150,27 @@ fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) ->
None => rest.push(object),
}
}
- if rest.is_empty() {
+ for (id, n) in definition_objects(d, &rest) {
+ out.entry(id).or_insert((n, false));
+ }
+ out
+}
+
+/// The objects of `ids` found in the art `d` draws from outside its layers: the art of its
+/// symbols, patterns and brushes, and opacity masks (in that art or in the layers).
+fn definition_objects(d: &Document, ids: &[u64]) -> std::collections::HashMap> {
+ let mut out = std::collections::HashMap::new();
+ if ids.is_empty() {
return out;
}
+ let ids: std::collections::HashSet = ids.iter().copied().collect();
let brushes = vectorcraft_brush::library(d);
let symbols = d.symbols.iter().map(|s| &*s.art);
let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a));
let layers = d.layers.iter().map(|a| &**a);
let mut look = |n: &Node| {
- if rest.contains(&n.id.0) {
- out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false));
+ if ids.contains(&n.id.0) {
+ out.entry(n.id.0).or_insert_with(|| Arc::new(n.clone()));
}
};
for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) {
@@ -231,10 +242,19 @@ fn refresh_deferred(s: &mut Session, p: &Value) -> Result {
let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone());
let ids = super::named_ids(p)?;
let objects = prune(&doc, &fx);
- if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) {
- return Err(EngineError::NoNode(*id));
+ // An object of the art drawn outside the layers is valid whether or not it has a stage.
+ let elsewhere: Vec = ids.iter().flatten().filter(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)).map(|id| id.0).collect();
+ let found = definition_objects(&doc, &elsewhere);
+ if let Some(id) = elsewhere.iter().find(|id| !found.contains_key(id)) {
+ return Err(EngineError::NoNode(NodeId(*id)));
}
- let ids: Option> = ids.map(|ids| ids.iter().map(|id| id.0).collect());
+ // Only the objects with a stage have anything to refresh.
+ let ids: Option> = ids.map(|ids| {
+ let mut ids: Vec = ids.iter().map(|id| id.0).filter(|id| objects.contains_key(id)).collect();
+ ids.sort_unstable();
+ ids.dedup();
+ ids
+ });
let counts = fx.refresh(ids.as_deref(), |targets| {
let mut renderer = vectorcraft_render::Renderer::new();
let mut whole = Vec::new();
@@ -656,12 +676,10 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result {
if !effect.is_some_and(|e| effects::is_three_d(&e.id)) {
return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0)));
}
- // Extrude & Bevel (Classic) has no lighting presets; its lights are set in its own options.
- if effect.is_some_and(|e| e.id == effects::CLASSIC_EXTRUDE) {
- return Err(bad(
- C,
- format!("object {}: Extrude & Bevel (Classic) at index {index} has no lighting presets; set its lights with effect.setParams", id.0),
- ));
+ // The classic 3D effects have no lighting presets; their lights are set in their own options.
+ if let Some(e) = effect.filter(|e| effects::is_classic_three_d(&e.id)) {
+ let name = effects::effect_info(&e.id).map_or("This effect", |info| info.label.trim_end_matches('…'));
+ return Err(bad(C, format!("object {}: {name} at index {index} has no lighting presets; set its lights with effect.setParams", id.0)));
}
}
let mut options = p.clone();
diff --git a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs
index 812ce09664..2dff0d23f1 100644
--- a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs
+++ b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs
@@ -115,3 +115,32 @@ fn lighting_presets_refuse_extrude_and_bevel_classic_and_leave_a_mixed_selection
let params = &s.doc().unwrap().doc.node(NodeId(classic)).unwrap().appearance.effects[0].params;
assert_eq!((params["lightRotation"].as_f64(), params["lightHeight"].as_f64()), (Some(135.0), Some(45.0)));
}
+
+#[test]
+fn lighting_presets_refuse_revolve_classic() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let id = s.execute("path.create", &json!({"d":"M100 40 L130 60 L130 140 L100 160"})).unwrap()["id"].as_u64().unwrap();
+ s.execute("paint.setFill", &json!({"color":"#3366cc"})).unwrap();
+ s.execute("effect.apply", &json!({"effect":"threeD.classicRevolve","item":null,"params":{"surface":"diffuse"}})).unwrap();
+ s.doc_mut().unwrap().mark_saved();
+ let before = s.doc().unwrap().doc.clone();
+ let err = s.execute("effect.lightingPreset", &json!({"ids":[id],"index":0,"item":null,"preset":"topLeft"})).unwrap_err().to_string();
+ assert!(err.contains(&format!("object {id}: Revolve (Classic) at index 0 has no lighting presets")), "{err}");
+ assert_eq!(s.doc().unwrap().doc, before);
+ assert!(!s.doc().unwrap().is_dirty());
+}
+
+#[test]
+fn lighting_presets_refuse_rotate_classic() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let id = s.execute("shape.rectangle", &json!({"x":100,"y":100,"width":80,"height":60})).unwrap()["id"].as_u64().unwrap();
+ s.execute("effect.apply", &json!({"effect":"threeD.classicRotate","item":null,"params":{"lightRotation":40}})).unwrap();
+ s.doc_mut().unwrap().mark_saved();
+ let before = s.doc().unwrap().doc.clone();
+ let err = s.execute("effect.lightingPreset", &json!({"ids":[id],"index":0,"item":null,"preset":"diffuse"})).unwrap_err().to_string();
+ assert!(err.contains(&format!("object {id}: Rotate (Classic) at index 0 has no lighting presets")), "{err}");
+ assert_eq!(s.doc().unwrap().doc, before);
+ assert!(!s.doc().unwrap().is_dirty());
+}
diff --git a/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs
index 122d937b98..104363a142 100644
--- a/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs
+++ b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs
@@ -4,7 +4,7 @@ use vectorcraft_doc::{NodeId, NodeKind};
use crate::Session;
-const TYPES: [&str; 5] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.revolve"];
+const TYPES: [&str; 6] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.classicRotate", "threeD.revolve"];
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs
index 6478cc81b8..02b82afedd 100644
--- a/crates/engine/src/cmd/fileio.rs
+++ b/crates/engine/src/cmd/fileio.rs
@@ -90,9 +90,9 @@ pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub(crate) use save::save as save_document;
pub use save::{
- SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option,
- remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder,
- templates_folder,
+ SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths,
+ reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
+ template_filters, templates_dialog_folder, templates_folder,
};
pub(crate) use screens::{
PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS,
@@ -124,7 +124,9 @@ pub(crate) use access::list_dir;
pub use access::write_file_with;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use access::{Placed, copy_new, file_metadata};
-pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file};
+pub(crate) use access::{
+ absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file,
+};
#[cfg(all(test, windows))]
pub(crate) use access::{document_path_on, document_path_with};
#[cfg(test)]
diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs
index 4dce2023bb..7b20326106 100644
--- a/crates/engine/src/cmd/fileio/access.rs
+++ b/crates/engine/src/cmd/fileio/access.rs
@@ -42,6 +42,29 @@ pub(crate) fn touched(f: impl FnOnce() -> T) -> (T, Vec) {
(out, seen)
}
+/// May a reply to the request being served name `path`, a file or folder the client did not give?
+/// Always without roots in force; with them, only inside the read roots ([`crate::file_access::check_read`]).
+pub(crate) fn shown(path: &str) -> bool {
+ crate::file_access::check_read(path).is_ok()
+}
+
+/// `path` as a reply to the request being served names it: whole when [`shown`], else its file name,
+/// after the last `/` or `\` whichever platform wrote it (empty for a path that ends in no name, such
+/// as `..` or a folder ending in a separator).
+pub(crate) fn shown_path(path: &str) -> std::borrow::Cow<'_, str> {
+ shown_link(path, None)
+}
+
+/// A link's `stored` path as a reply to the request being served names it: whole when the file it
+/// names (`resolved`, else `stored` itself) is [`shown`], else its file name as [`shown_path`] gives it.
+pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::borrow::Cow<'a, str> {
+ if shown(resolved.unwrap_or(stored)) {
+ return std::borrow::Cow::Borrowed(stored);
+ }
+ let name = stored.rsplit(vectorcraft_doc::links::path_separators(stored)).next().unwrap_or_default();
+ std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name })
+}
+
/// `path` checked for writing against the automation roots in force.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn may_write(path: &str) -> Result<()> {
diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs
index dd2b257f2a..591c9097dd 100644
--- a/crates/engine/src/cmd/fileio/commands.rs
+++ b/crates/engine/src/cmd/fileio/commands.rs
@@ -54,7 +54,7 @@ pub fn specs() -> Vec {
"Export for Screens",
["File", "Export"],
None,
- "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, , , , , , , and ; a trailing .ext is dropped, as in `COMPANY-Logo--####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused",
+ "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, , , , , , , and ; a trailing .ext is dropped, as in `COMPANY-Logo--####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused",
has_doc,
screens::export_for_screens
),
@@ -63,7 +63,7 @@ pub fn specs() -> Vec {
"Export Presets",
[],
None,
- "{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset",
+ "{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones (a folder outside the automation read roots is left out of settings); any name works as document.exportForScreens' exportPreset",
always,
screens::presets_list
),
@@ -99,7 +99,7 @@ pub fn specs() -> Vec {
"Export Export Presets",
[],
None,
- "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}",
+ "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON; a folder outside the automation read roots is left out); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}",
always,
screens::presets_export
),
@@ -153,7 +153,7 @@ pub fn specs() -> Vec {
"Export for Screens Settings",
[],
None,
- "{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them)",
+ "{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them; a folder outside the automation read roots is left out)",
has_doc,
screens::export_settings
),
diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs
index 301a7c79fe..d010dab3d0 100644
--- a/crates/engine/src/cmd/fileio/save.rs
+++ b/crates/engine/src/cmd/fileio/save.rs
@@ -1034,11 +1034,47 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result
let plan = save_plan(s, mode, p)?;
let job = save_job(s, plan)?;
let uid = s.doc()?.uid;
- let out = job.write_streamed(&mut super::write_file_with)?;
+ let mut out = job.write_streamed(&mut super::write_file_with)?;
job.complete(s, uid);
+ leave_out_unreadable_save_paths(&mut out, str_param(p, "path"));
Ok(out)
}
+/// The reply `out` of a save with the `path` param `given`, as the request being served may see it
+/// ([`super::access::shown`]). A save without a path writes the document's own file: its `path` is
+/// left out, and the `files` and `linked` written beside it are named by their file names, when they
+/// are outside the read roots. The suggested `folder` of a reply without a path is left out when it
+/// is outside them. Without roots in force `out` is unchanged.
+pub fn leave_out_unreadable_save_paths(out: &mut Value, given: Option<&str>) {
+ use super::access::{shown, shown_path};
+
+ if crate::file_access::current().is_none() {
+ return;
+ }
+ let Some(o) = out.as_object_mut() else { return };
+ if o.get("folder").and_then(Value::as_str).is_some_and(|f| !shown(f)) {
+ o.remove("folder");
+ }
+ // Every path the reply names then is the one the client gave, or beside it.
+ if given.is_some_and(|g| !g.is_empty()) {
+ return;
+ }
+ if o.get("path").and_then(Value::as_str).is_some_and(|p| !shown(p)) {
+ o.remove("path");
+ }
+ for key in ["files", "linked"] {
+ if let Some(Value::Array(rows)) = o.get_mut(key) {
+ for row in rows {
+ if let Value::String(p) = row
+ && !shown(p)
+ {
+ *row = json!(shown_path(p));
+ }
+ }
+ }
+ }
+}
+
fn can_revert(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
match (&st.path, st.is_dirty()) {
@@ -1125,7 +1161,7 @@ pub(super) fn specs() -> Vec {
"Save Document",
[],
None,
- "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to _.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
+ "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to _.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs
index 56c0d808a5..bcc4bcb178 100644
--- a/crates/engine/src/cmd/fileio/screens.rs
+++ b/crates/engine/src/cmd/fileio/screens.rs
@@ -644,12 +644,86 @@ impl Output {
/// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with
/// the params remembered in the document (`document.exportSettings`).
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result {
+ // A folder the client gave, as against the one its `exportPreset` keeps.
+ let given = p.get("folder").is_some();
let p = with_preset(s, p)?;
- let r = export(s, &p)?;
+ let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
+ let mut r = export(s, &p).map_err(|e| match hidden {
+ Some(folder) => leave_out_folder_from_error(e, folder),
+ None => e,
+ })?;
remember(s, &p);
+ if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) {
+ leave_out_unreadable_folder(&mut r, folder);
+ }
Ok(r)
}
+/// An error of an export into `folder`, a folder outside the read roots that the client did not
+/// give: `folder` as the path the error is about names it by its own name
+/// ([`super::access::shown_path`]), and the rest of the message stays.
+fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError {
+ let name = super::access::shown_path(folder);
+ match e {
+ EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)),
+ EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) },
+ e => e,
+ }
+}
+
+/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start
+/// of `msg` (`: `), after a `: ` (`... outside the write root : `) or
+/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message
+/// names stays whole, even one inside `folder`.
+fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String {
+ const BEFORE: [&str; 2] = [": ", "cannot resolve "];
+ let mut out = String::with_capacity(msg.len());
+ let mut rest = msg;
+ loop {
+ if let Some(after) = rest.strip_prefix(folder)
+ && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' '))
+ {
+ out.push_str(name);
+ rest = after;
+ }
+ let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break };
+ out.push_str(&rest[..i + len]);
+ rest = &rest[i + len..];
+ }
+ out.push_str(rest);
+ out
+}
+
+/// The reply `r` of an export into `folder`, a folder the client did not give, as the request being
+/// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's
+/// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`),
+/// as the reply of an export to a ZIP names them.
+fn leave_out_unreadable_folder(r: &mut Value, folder: &str) {
+ if super::access::shown(folder) {
+ return;
+ }
+ let Some(o) = r.as_object_mut() else { return };
+ o.remove("path");
+ let inside = format!("{folder}/");
+ if let Some(Value::Array(files)) = o.get_mut("files") {
+ for file in files {
+ // A ZIP's files are named relative to it already.
+ if let Some(name) = file.as_str().and_then(|path| path.strip_prefix(&inside)) {
+ *file = json!(name);
+ }
+ }
+ }
+}
+
+/// Export settings kept in the document or a preset, as the request being served may see them: a
+/// `folder` outside the read roots ([`super::access::shown`]) is left out.
+fn leave_out_unreadable_settings_folder(mut settings: Map) -> Map {
+ if settings.get("folder").and_then(Value::as_str).is_some_and(|f| !f.is_empty() && !super::access::shown(f)) {
+ settings.remove("folder");
+ }
+ settings
+}
+
// ---------- export presets ----------
/// A saved batch export: what to write and how to name and sort it, to use again
@@ -727,7 +801,13 @@ pub fn preset_list(saved: &[ScreenExportPreset]) -> Value {
}
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result {
- Ok(json!({ "presets": preset_list(&s.prefs.screen_export_presets) }))
+ let saved: Vec = s
+ .prefs
+ .screen_export_presets
+ .iter()
+ .map(|p| ScreenExportPreset { name: p.name.clone(), settings: leave_out_unreadable_settings_folder(p.settings.clone()) })
+ .collect();
+ Ok(json!({ "presets": preset_list(&saved) }))
}
/// The [`PRESET_KEYS`] of `given`, refused as an error of command `cmd` when an export would
@@ -831,6 +911,8 @@ pub(super) fn presets_export(s: &mut Session, p: &Value) -> Result {
return Err(too_many());
}
let count = presets.len();
+ let presets: Vec =
+ presets.into_iter().map(|p| ScreenExportPreset { settings: leave_out_unreadable_settings_folder(p.settings), ..p }).collect();
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FILE_FORMAT.into(), presets }).map_err(|e| bad(EXPORT, e.to_string()))?;
if text.len() > MAX_PRESET_FILE_BYTES {
return Err(bad(EXPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes")));
@@ -1388,12 +1470,31 @@ pub(crate) fn store_settings(s: &mut Session, mut settings: Map)
/// `document.exportSettings`: the Export for Screens settings the document last exported with.
pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result {
- Ok(json!({ "settings": s.doc()?.doc.export_settings }))
+ Ok(json!({ "settings": leave_out_unreadable_settings_folder(s.doc()?.doc.export_settings.clone()) }))
}
#[cfg(test)]
mod tests {
- use super::{Output, ScreenSize, safe_file_name};
+ use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name};
+
+ /// A hidden export folder is named by its name where a message is about it, and a root that
+ /// starts with the same text, or a longer path, stays whole.
+ #[test]
+ fn a_hidden_folder_is_named_by_its_name_in_a_message() {
+ let f = "/work/out";
+ let cases = [
+ (
+ "automation path rejected: outside the write root /work/out/in: /work/out",
+ "automation path rejected: outside the write root /work/out/in: out",
+ ),
+ ("automation path rejected: cannot resolve /work/out: denied", "automation path rejected: cannot resolve out: denied"),
+ ("/work/out/2x/a.png: disk full", "out/2x/a.png: disk full"),
+ ("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"),
+ ];
+ for (msg, want) in cases {
+ assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}");
+ }
+ }
/// Files given one name are numbered in turn, each numbering going on from the last one (so
/// thousands of them don't each try every number used before), and a file asking again for
diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs
index 6236d281e2..ebe3b76da9 100644
--- a/crates/engine/src/cmd/fileio/tests.rs
+++ b/crates/engine/src/cmd/fileio/tests.rs
@@ -48,6 +48,19 @@ fn image_of(s: &Session) -> vectorcraft_doc::ImageObject {
}
}
+/// A path outside a confined request's read roots shows as its file name, after the last `/` or `\`
+/// on every platform; one that ends in no name shows as nothing. Without roots it shows whole.
+#[test]
+fn a_hidden_path_shows_as_its_file_name_on_every_platform() {
+ let windows = "C:\\Users\\someone\\art\\red.png";
+ crate::file_access::confine(Some(&crate::file_access::AutomationRoots::none()), || {
+ for (path, name) in [("/home/someone/art/red.png", "red.png"), (windows, "red.png"), ("/home/someone/..", ""), ("C:\\Users\\someone\\", "")] {
+ assert_eq!(shown_path(path), name, "{path}");
+ }
+ });
+ assert_eq!(shown_path(windows), windows);
+}
+
#[test]
fn opens_every_readable_format() {
let mut s = session(200.0, 100.0, 1);
diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs
index b0dcd368ec..86ee164998 100644
--- a/crates/engine/src/cmd/fileio/tests_screens.rs
+++ b/crates/engine/src/cmd/fileio/tests_screens.rs
@@ -667,6 +667,118 @@ fn a_numbered_file_is_written_into_the_first_rows_folder() {
assert_eq!(std::fs::read_dir(dir).unwrap().count(), 1, "one sub-folder");
}
+/// An export into the folder an export preset keeps, for a client whose read roots don't hold that
+/// folder: the reply leaves out the ZIP's path and names the files relative to the folder. A folder
+/// inside the read roots, and one the client gives, stay whole.
+#[cfg(not(target_arch = "wasm32"))]
+#[test]
+fn a_presets_folder_outside_the_read_root_is_left_out_of_the_reply() {
+ use crate::file_access::{AutomationRoots, confine};
+
+ let mut s = session(1);
+ let tmp = Folder::new("preset-roots");
+ let (inside, out) = (tmp.0.join("in"), tmp.0.join("out"));
+ for f in [&inside, &out] {
+ std::fs::create_dir_all(f).unwrap();
+ }
+ let folder = out.to_string_lossy().replace('\\', "/");
+ let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder});
+ s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
+ let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap();
+ let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap();
+ let run = |s: &mut Session, roots: &Option>, p: Value| confine(roots.as_ref(), || export(s, p));
+
+ let r = run(&mut s, &write_only, json!({"exportPreset": "Kept"}));
+ assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}");
+ assert!(out.join("1x/Artboard-1.png").is_file());
+ let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true}));
+ assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}");
+ assert!(!r.to_string().contains(&folder), "{r}");
+ // Inside the read roots, or given by the client: whole.
+ assert_eq!(run(&mut s, &both, json!({"exportPreset": "Kept"}))["files"], json!([format!("{folder}/1x/Artboard-1.png")]));
+ let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true}));
+ assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}");
+}
+
+/// An export into the folder an export preset keeps, refused because that folder is outside the
+/// client's read and write roots: the error names the folder by its name only. A folder the client
+/// gives stays whole in the error.
+#[cfg(not(target_arch = "wasm32"))]
+#[test]
+fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_name() {
+ use crate::file_access::{AutomationRoots, confine};
+
+ let mut s = session(1);
+ let tmp = Folder::new("preset-refused");
+ let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out"));
+ for f in [&inside, &out] {
+ std::fs::create_dir_all(f).unwrap();
+ }
+ let folder = out.to_string_lossy().replace('\\', "/");
+ let settings = json!({"formats": [{"format": "png"}], "folder": folder});
+ s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
+ let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
+ let mut run = |p: Value| confine(confined.as_ref(), || s.execute("document.exportForScreens", &p)).unwrap_err().to_string();
+
+ let e = run(json!({"exportPreset": "Kept"}));
+ assert!(e.contains("outside the write root") && e.contains("kept-out") && !e.contains(&folder), "{e}");
+ let e = run(json!({"exportPreset": "Kept", "folder": folder}));
+ assert!(e.contains(&folder), "{e}");
+ assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written");
+ // A preset folder that holds the write root: the root stays whole in the error.
+ let parent = tmp.0.to_string_lossy().replace('\\', "/");
+ let settings = json!({"formats": [{"format": "png"}], "folder": parent});
+ s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap();
+ let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string();
+ let root = inside.display().to_string();
+ let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned();
+ assert!(e.contains(&format!("root {root}: {name}")), "{e}");
+}
+
+/// The export settings the document keeps and the saved presets, read by a client whose read roots
+/// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and
+/// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest.
+/// Inside the read roots the folder stays.
+#[cfg(not(target_arch = "wasm32"))]
+#[test]
+fn kept_settings_leave_out_a_folder_outside_the_read_root() {
+ use crate::file_access::{AutomationRoots, confine};
+
+ let mut s = session(1);
+ let tmp = Folder::new("settings-roots");
+ let (inside, out) = (tmp.0.join("in"), tmp.0.join("out"));
+ for f in [&inside, &out] {
+ std::fs::create_dir_all(f).unwrap();
+ }
+ let folder = out.to_string_lossy().replace('\\', "/");
+ let settings = json!({"formats": [{"format": "svg"}], "folder": folder});
+ s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
+ export(&mut s, json!({"exportPreset": "Kept"}));
+ let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
+ let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[inside.as_path()]).unwrap();
+ let ask = |s: &mut Session, roots: &Option>, cmd: &str, p: Value| {
+ confine(roots.as_ref(), || s.execute(cmd, &p).unwrap())
+ };
+ let saved = |list: &Value| list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Kept").unwrap()["settings"].clone();
+
+ let r = ask(&mut s, &confined, "document.exportSettings", json!({}));
+ assert_eq!((r["settings"].get("folder"), &r["settings"]["formats"]), (None, &json!([{"format": "svg"}])), "{r}");
+ let r = ask(&mut s, &confined, "document.exportPresets.list", json!({}));
+ assert_eq!(saved(&r), json!({"formats": [{"format": "svg"}]}), "{r}");
+ let r = ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"]}));
+ let data = r["data"].as_str().unwrap();
+ assert!(!data.contains(&folder) && data.contains("svg"), "{data}");
+ let file = inside.join("kept.vcexportpresets");
+ let path = file.to_string_lossy().replace('\\', "/");
+ ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"], "path": path}));
+ assert!(!std::fs::read_to_string(&file).unwrap().contains(&folder));
+
+ // Inside the read roots: whole.
+ assert_eq!(ask(&mut s, &both, "document.exportSettings", json!({}))["settings"]["folder"], json!(folder));
+ assert_eq!(saved(&ask(&mut s, &both, "document.exportPresets.list", json!({})))["folder"], json!(folder));
+ assert!(ask(&mut s, &both, "document.exportPresets.export", json!({"names": ["Kept"]}))["data"].as_str().unwrap().contains(&folder));
+}
+
#[test]
fn format_settings_apply_to_their_rows_and_rows_win() {
let mut s = session(1);
diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs
index ee6d7e3c89..8d6f53d55b 100644
--- a/crates/engine/src/cmd/package.rs
+++ b/crates/engine/src/cmd/package.rs
@@ -12,7 +12,7 @@ use vectorcraft_doc::{Document, LinkInfo};
use serde_json::{Value, json};
-use super::fileio::{create_dir, write_file};
+use super::fileio::{create_dir, document_path, shown_link, shown_path, write_file};
use super::links::LinkBase;
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param, unique_name};
@@ -24,7 +24,7 @@ pub fn specs() -> Vec {
"Package",
[],
None,
- "{folder?, name?: (default: \" Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true ( Report.txt)} copy the saved document as it is now into folder/name as .vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \".zip\", dataBase64} (a zip of the files in /) instead of folder",
+ "{folder?, name?: (default: \" Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true ( Report.txt)} copy the saved document as it is now into folder/name as .vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \".zip\", dataBase64} (a zip of the files in /) instead of folder. The warnings and the report name a file outside the automation read roots by its file name",
has_doc,
package
)]
@@ -178,8 +178,10 @@ impl<'a> Collector<'a> {
/// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the
/// copy of `source` finds it only by its name, beside it.
+ /// `path` is the link's path as the warning names it.
fn renamed(&mut self, lines: &mut Vec, source: &str, path: &str, name: &str, copy: &str) {
if Path::new(copy).file_name().and_then(std::ffi::OsStr::to_str) != Some(name) {
+ let source = shown_path(source);
let message = format!(
"{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview"
);
@@ -207,11 +209,13 @@ impl<'a> Collector<'a> {
let section = self.sections.len();
self.sections.push(vec![]);
let mut lines = vec![];
- // The packaged document's sections read as before; a placed document's name its file.
+ // The packaged document's sections read as before; a placed document's name its file. The
+ // report and the warnings name a file outside a confined request's read roots by its name.
+ let shown_source = shown_path(source);
let heading = |lines: &mut Vec, title: &str| {
lines.push(title.into());
if depth > 0 {
- lines.push(format!("Document: {source}"));
+ lines.push(format!("Document: {shown_source}"));
}
};
// A file's saved path is only meaningful inside its own document.
@@ -243,17 +247,21 @@ impl<'a> Collector<'a> {
heading(&mut lines, "LINKED FILES");
for file in linked_files(doc) {
let found_path = file.found_path.as_deref().unwrap_or(&file.path);
+ // The stored path, whole when the place it names (read from this document's folder) is
+ // inside the read roots. A file found elsewhere does not show a stored path outside them.
+ let resolved = document_path(&file.path, Some(source)).ok();
+ let shown = shown_link(&file.path, resolved.as_deref()).into_owned();
if let Some(ancestor) = self.visiting.get(found_path).cloned() {
// The ancestor is already included (possibly the root document). A
// cyclic reference cannot have a final content hash until both files
// have been written, so leave its size/hash unspecified, not stale.
- self.warn(&mut lines, format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path));
+ self.warn(&mut lines, format!("{shown}: circular placed-document link to {ancestor}; stopped following the cycle"));
local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None });
continue;
}
if let Some(existing) = self.copies.get(found_path).cloned() {
if by_name {
- self.renamed(&mut lines, source, &file.path, &file.name, &existing.name);
+ self.renamed(&mut lines, source, &shown, &file.name, &existing.name);
}
// Another document may place other pages of a PDF copied already.
if self.opts.copy_fonts
@@ -265,8 +273,8 @@ impl<'a> Collector<'a> {
continue;
}
let Some(mut bytes) = file.bytes else {
- lines.push(format!("{}: not found ({}), not copied", file.name, file.path));
- self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({source})", file.name) });
+ lines.push(format!("{}: not found ({shown}), not copied", file.name));
+ self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({shown_source})", file.name) });
continue;
};
if self.copies.len() >= 10_000 {
@@ -277,7 +285,7 @@ impl<'a> Collector<'a> {
let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken });
let destination_file = format!("{dir}{unique}");
if by_name {
- self.renamed(&mut lines, source, &file.path, &file.name, &destination_file);
+ self.renamed(&mut lines, source, &shown, &file.name, &destination_file);
}
// A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file
// read from its editing data links are copied beside it.
@@ -285,7 +293,7 @@ impl<'a> Collector<'a> {
let placed_file = pages.is_some();
if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) {
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
- self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
+ self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links"));
} else {
self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?;
}
@@ -296,7 +304,7 @@ impl<'a> Collector<'a> {
}
if placed_file && super::place::document::is_native(&file.name, &bytes) {
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
- self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
+ self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links"));
} else {
match vectorcraft_format::load_file(&bytes) {
Ok(saved) => {
@@ -320,8 +328,7 @@ impl<'a> Collector<'a> {
Err(e) => self.warn(
&mut lines,
format!(
- "{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked",
- file.path
+ "{shown}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked"
),
),
}
@@ -329,7 +336,7 @@ impl<'a> Collector<'a> {
}
Err(e) => self.warn(
&mut lines,
- format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path),
+ format!("{shown}: unreadable linked document ({e}); copied unchanged, its own links were not collected"),
),
}
}
@@ -337,7 +344,7 @@ impl<'a> Collector<'a> {
let copy = Copied { name: destination_file.clone(), size: Some(bytes.len() as u64), hash: Some(hash_bytes(&bytes)) };
self.copies.insert(found_path.to_string(), copy.clone());
local.insert(file.path.clone(), copy);
- lines.push(format!("{} → {destination_file}", file.path));
+ lines.push(format!("{shown} → {destination_file}"));
self.entries.push((destination_file, bytes));
}
lines.push(String::new());
@@ -518,7 +525,7 @@ fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Res
// The report.
if let Some(info) = info {
let report_name = free_name(&format!("{stem} Report.txt"), &mut taken);
- let mut text = format!("Package: {doc_name}\nFrom: {doc_path}\n\n");
+ let mut text = format!("Package: {doc_name}\nFrom: {}\n\n", shown_path(&doc_path));
lines.iter().for_each(|l| text.push_str(&format!("{l}\n")));
text.push_str(&info);
entries.push((report_name, text.into_bytes()));
diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs
index 011dfc131c..8fd2a0ea5e 100644
--- a/crates/engine/src/cmd/patternlib.rs
+++ b/crates/engine/src/cmd/patternlib.rs
@@ -23,8 +23,10 @@ pub struct BuiltinPatternLibrary {
}
/// The built-in pattern libraries (Window → Swatch Libraries → Patterns), in menu order.
-pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] =
- &[BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }];
+pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[
+ BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns },
+ BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures },
+];
/// The library category of the built-in pattern libraries.
pub const CATEGORY: &str = "patterns";
@@ -154,6 +156,106 @@ fn geometric_patterns(d: &mut Document) {
add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]);
}
+/// SplitMix64, a small generator with a fixed seed per pattern, so a grit pattern draws the same
+/// marks every time it is made.
+struct SplitMix64(u64);
+
+impl SplitMix64 {
+ fn next(&mut self) -> u64 {
+ self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
+ let mut z = self.0;
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+ z ^ (z >> 31)
+ }
+
+ /// A number from `lo` up to, but not including, `hi`.
+ fn range(&mut self, lo: f64, hi: f64) -> f64 {
+ lo + (self.next() >> 11) as f64 / (1u64 << 53) as f64 * (hi - lo)
+ }
+}
+
+/// A round dot of radius `r` at (`x`, `y`).
+fn dot(x: f64, y: f64, r: f64) -> PathData {
+ shapes::ellipse(Rect::new(x - r, y - r, x + r, y + r))
+}
+
+/// An irregular fleck of four to seven corners about (`x`, `y`), at most `r` from it.
+fn fleck(rng: &mut SplitMix64, x: f64, y: f64, r: f64) -> PathData {
+ use std::f64::consts::TAU;
+ let n = 4 + (rng.next() % 4) as usize;
+ let turn = rng.range(0.0, TAU);
+ let points: Vec<(f64, f64)> = (0..n)
+ .map(|k| {
+ let a = turn + TAU * (k as f64 + rng.range(-0.3, 0.3)) / n as f64;
+ let reach = r * rng.range(0.55, 1.0);
+ (x + reach * a.cos(), y + reach * a.sin())
+ })
+ .collect();
+ polygon(&points)
+}
+
+/// A thin straight scratch `len` long and `width` wide, centered on (`x`, `y`) at a random angle.
+fn scratch(rng: &mut SplitMix64, x: f64, y: f64, len: f64, width: f64) -> PathData {
+ let (s, c) = rng.range(0.0, std::f64::consts::PI).sin_cos();
+ let (hx, hy, wx, wy) = (c * len / 2.0, s * len / 2.0, -s * width / 2.0, c * width / 2.0);
+ polygon(&[(x - hx - wx, y - hy - wy), (x + hx - wx, y + hy - wy), (x + hx + wx, y + hy + wy), (x - hx + wx, y - hy + wy)])
+}
+
+/// A grit pattern of `count` marks scattered over a square grid tile of side `size`, drawn from
+/// `seed` (`mark` draws one at a point). A mark that reaches past an edge is kept whole: the tiling
+/// draws the part outside the tile on the next tile, so the tiles join without a seam.
+fn grit(d: &mut Document, name: &str, size: f64, seed: u64, count: usize, mark: impl Fn(&mut SplitMix64, f64, f64) -> PathData) {
+ let mut rng = SplitMix64(seed);
+ let marks = (0..count)
+ .map(|_| {
+ let (x, y) = (rng.range(0.0, size), rng.range(0.0, size));
+ mark(&mut rng, x, y)
+ })
+ .collect();
+ add(d, name, size, size, TileType::Grid, marks);
+}
+
+/// Grit Textures: seamless black grain, grit, sand, speckle, stipple, scratch and fleck textures on
+/// a clear background, each drawn from its own fixed seed.
+fn grit_textures(d: &mut Document) {
+ grit(d, "Fine Grain", 24.0, 1, 320, |rng, x, y| dot(x, y, rng.range(0.15, 0.35)));
+ grit(d, "Coarse Grit", 30.0, 2, 150, |rng, x, y| {
+ let r = rng.range(0.45, 1.1);
+ fleck(rng, x, y, r)
+ });
+ grit(d, "Sand", 24.0, 3, 280, |rng, x, y| {
+ let r = rng.range(0.2, 0.45);
+ fleck(rng, x, y, r)
+ });
+ grit(d, "Speckle", 30.0, 4, 90, |rng, x, y| dot(x, y, rng.range(0.3, 0.9)));
+ // Stipple: one dot in each cell of a 12 × 12 grid, moved a little off the cell's center.
+ let mut rng = SplitMix64(5);
+ let (size, cells) = (24.0, 12);
+ let step = size / f64::from(cells);
+ let dots = (0..cells)
+ .flat_map(|row| (0..cells).map(move |col| (col, row)))
+ .map(|(col, row)| {
+ let x = (f64::from(col) + 0.5) * step + rng.range(-0.3, 0.3) * step;
+ let y = (f64::from(row) + 0.5) * step + rng.range(-0.3, 0.3) * step;
+ dot(x, y, rng.range(0.3, 0.45))
+ })
+ .collect();
+ add(d, "Stipple", size, size, TileType::Grid, dots);
+ grit(d, "Scratches", 36.0, 6, 45, |rng, x, y| {
+ let (len, width) = (rng.range(2.0, 7.0), rng.range(0.15, 0.35));
+ scratch(rng, x, y, len, width)
+ });
+ grit(d, "Sparse Flecks", 36.0, 7, 32, |rng, x, y| {
+ let r = rng.range(0.35, 0.8);
+ fleck(rng, x, y, r)
+ });
+ grit(d, "Dense Flecks", 18.0, 8, 220, |rng, x, y| {
+ let r = rng.range(0.2, 0.5);
+ fleck(rng, x, y, r)
+ });
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -168,4 +270,60 @@ mod tests {
assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}");
}
}
+
+ fn grit_doc() -> Document {
+ let mut d = Document::new(100.0, 100.0);
+ grit_textures(&mut d);
+ d
+ }
+
+ #[test]
+ fn grit_textures_draw_the_same_marks_every_time() {
+ let (a, b) = (grit_doc(), grit_doc());
+ assert_eq!(a.patterns.len(), 8);
+ assert_eq!(a.patterns, b.patterns);
+ }
+
+ /// Every grit tile is 18 to 36 pt with at most 600 marks, each with an area, touching the tile
+ /// and near it. Marks reach past the edges, and the tiling draws a tile's art on the next tile
+ /// across each edge a mark crosses. No mark is another mark moved by a whole tile, which the tiling would draw
+ /// twice in one place.
+ #[test]
+ fn grit_tiles_join_without_a_seam() {
+ let mut crossing = 0;
+ for p in &grit_doc().patterns {
+ let (w, h) = (p.width(), p.height());
+ assert!((18.0..=36.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name);
+ assert!(!p.art.is_empty() && p.art.len() <= 600, "{}: {} marks", p.name, p.art.len());
+ let bounds: Vec = p
+ .art
+ .iter()
+ .map(|n| match &n.kind {
+ vectorcraft_doc::NodeKind::Path { path, .. } => path.bounds().unwrap_or_else(|| panic!("{}: an empty mark", p.name)),
+ _ => panic!("{}: a mark that is not a path", p.name),
+ })
+ .collect();
+ let near = |a: &Rect, b: Rect| {
+ (a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9
+ };
+ let band = p.tile.inflate(w / 4.0, h / 4.0);
+ for b in &bounds {
+ assert!(b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name);
+ assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name);
+ assert!(b.x0 >= band.x0 && b.y0 >= band.y0 && b.x1 <= band.x1 && b.y1 <= band.y1, "{}: {b:?} is too far out", p.name);
+ // The tile past each edge the mark crosses draws this tile's art.
+ let (dx, dy) = (f64::from(i8::from(b.x1 > w) - i8::from(b.x0 < 0.0)), f64::from(i8::from(b.y1 > h) - i8::from(b.y0 < 0.0)));
+ for (x, y) in [(dx, 0.0), (0.0, dy), (dx, dy)] {
+ let next = p.tile + kurbo::Vec2::new(x * w, y * h);
+ assert!(p.offsets_covering(next).contains(&kurbo::Vec2::ZERO), "{}: {b:?} is cut off at {next:?}", p.name);
+ }
+ crossing += usize::from(dx != 0.0 || dy != 0.0);
+ for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] {
+ let moved = *b + kurbo::Vec2::new(dx, dy);
+ assert!(!bounds.iter().any(|o| near(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name);
+ }
+ }
+ }
+ assert!(crossing > 0, "no mark reaches past an edge");
+ }
}
diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs
index 5c03317fa9..fdc30b4928 100644
--- a/crates/engine/src/cmd/plugin_call/reviewed.rs
+++ b/crates/engine/src/cmd/plugin_call/reviewed.rs
@@ -108,7 +108,9 @@ pub(super) const ALLOWED: &[&str] = &[
"brush.move",
"brush.new",
"brush.options",
+ "brush.placeArt",
"brush.remove",
+ "brush.replaceArt",
"brush.selectionArt",
"brush.setCurrent",
"brush.unused",
diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs
index 3424da3ddd..fce76bcc27 100644
--- a/crates/engine/src/cmd/recovery.rs
+++ b/crates/engine/src/cmd/recovery.rs
@@ -19,7 +19,9 @@
//! while their copies are restored or discarded the area is held, so two apps launched together
//! never both take it. An area's lock file or folder can be removed while its app runs, so each
//! recovery save and heartbeat checks that the lock still holds the area, and locks it again or
-//! takes a new area.
+//! takes a new area. The copies it had in the old area stay there until it has written them again
+//! in the new one, and an entry `/keeps-` ([`mark_left`]) tells other apps
+//! that those copies belong to a running app, so none of them offers or discards them meanwhile.
//!
//! A heartbeat alone can't tell a gone app from one whose timers stopped (a background tab), so
//! when unsure an area counts as held, and its app repairs what another app did meanwhile: each
@@ -61,6 +63,9 @@ const BLOB_EXT: &str = ".blob";
/// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix
/// seconds, as text).
const BEAT: &str = "heartbeat";
+/// The start of an entry ` /keeps-`: the app holding ` ` still keeps the copies
+/// it left in `` ([`mark_left`]).
+const KEEPS: &str = "keeps-";
/// The least time without a heartbeat after which an area's app counts as gone (seconds); else
/// three Data Recovery intervals.
const MIN_STALE: i64 = 180;
@@ -274,7 +279,60 @@ impl FolderStore {
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
- builder.create(dir).map_err(|e| format!("{}: {e}", dir.display()))
+ builder.create(dir).map_err(|e| Self::folder_error(dir, &e, true))
+ }
+
+ /// The first folder on the way to `dir` (`dir` included), from the top down, that is not a
+ /// folder (`true`) or is missing (`false`). None when every one of them is a folder or one of
+ /// them can't be checked.
+ fn in_the_way(dir: &std::path::Path) -> Option<(&std::path::Path, bool)> {
+ let mut way: Vec<&std::path::Path> = dir.ancestors().filter(|p| !p.as_os_str().is_empty()).collect();
+ way.reverse();
+ for p in way {
+ match std::fs::metadata(p) {
+ Ok(m) if m.is_dir() => {}
+ Ok(_) => return Some((p, true)),
+ Err(m) if m.kind() == std::io::ErrorKind::NotFound => return Some((p, false)),
+ Err(_) => return None,
+ }
+ }
+ None
+ }
+
+ /// The message for error `e` on folder `dir`, which was being made (`making`) or read. It
+ /// names the first folder on the way to `dir` that is not a folder, or is missing (when
+ /// `making`, the one that couldn't be made), and otherwise `dir`.
+ fn folder_error(dir: &std::path::Path, e: &std::io::Error, making: bool) -> String {
+ match Self::in_the_way(dir) {
+ Some((p, true)) => format!("{} is not a folder: {e}", p.display()),
+ Some((p, false)) if making => format!("couldn't make {}: {e}", p.display()),
+ Some((p, false)) if p != dir => format!("{} is missing", p.display()),
+ _ => format!("{}: {e}", dir.display()),
+ }
+ }
+
+ /// The message for error `e` on file `f`. It names the first folder on the way to `f` that is
+ /// not a folder or is missing, and otherwise `f`.
+ fn file_error(f: &std::path::Path, e: &std::io::Error) -> String {
+ match f.parent().and_then(Self::in_the_way) {
+ Some((p, true)) => format!("{} is not a folder: {e}", p.display()),
+ Some((p, false)) => format!("{} is missing", p.display()),
+ None => format!("{}: {e}", f.display()),
+ }
+ }
+
+ /// The outcome `r` of removing file `f`. A file that is not there counts as removed, unless a
+ /// file is on the way to it: Windows reports that as a missing file, and it is named as for a
+ /// listing.
+ pub(crate) fn removed(f: &std::path::Path, r: std::io::Result<()>) -> std::result::Result<(), String> {
+ match r {
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => match f.parent().and_then(Self::in_the_way) {
+ Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())),
+ _ => Ok(()),
+ },
+ Err(e) => Err(Self::file_error(f, &e)),
+ Ok(()) => Ok(()),
+ }
}
/// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix)
@@ -282,7 +340,7 @@ impl FolderStore {
/// linked to a folder of its own in a shared recovery folder looked free, and claiming it
/// wrote the copies through the link.
fn own_area(dir: &std::path::Path) -> std::result::Result<(), String> {
- let m = std::fs::symlink_metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
+ let m = std::fs::symlink_metadata(dir).map_err(|e| Self::folder_error(dir, &e, false))?;
if !m.file_type().is_dir() {
return Err(format!("{}: not a folder of its own", dir.display()));
}
@@ -294,11 +352,17 @@ impl FolderStore {
}
/// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist.
+ /// A file on the way to `dir` is an error: Windows reports such a folder as missing.
fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result, String> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
- Err(e) => return Err(format!("{}: {e}", dir.display())),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ return match Self::in_the_way(dir) {
+ Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())),
+ _ => Ok(vec![]),
+ };
+ }
+ Err(e) => return Err(Self::folder_error(dir, &e, false)),
};
Ok(entries
.filter_map(|e| e.ok())
@@ -333,7 +397,7 @@ impl FolderStore {
match e.kind() {
std::io::ErrorKind::NotFound => Ok(Lock::Busy),
std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy),
- _ => Err(format!("{}: {e}", path.display())),
+ _ => Err(Self::file_error(path, &e)),
}
}
@@ -345,7 +409,7 @@ impl FolderStore {
Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy),
// The lock file was removed meanwhile, and locking it failed instead.
Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy),
- Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())),
+ Err(std::fs::TryLockError::Error(e)) => Err(Self::file_error(path, &e)),
}
}
@@ -393,10 +457,10 @@ impl RecoveryStore for FolderStore {
if let Some(dir) = f.parent() {
Self::own_area(dir)?;
}
- if !std::fs::symlink_metadata(&f).map_err(|e| format!("{}: {e}", f.display()))?.file_type().is_file() {
+ if !std::fs::symlink_metadata(&f).map_err(|e| Self::file_error(&f, &e))?.file_type().is_file() {
return Err(format!("{}: not a plain file", f.display()));
}
- std::fs::read(&f).map_err(|e| format!("{}: {e}", f.display()))
+ std::fs::read(&f).map_err(|e| Self::file_error(&f, &e))
}
fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
let f = self.file(name)?;
@@ -404,14 +468,11 @@ impl RecoveryStore for FolderStore {
Self::create_area(dir)?;
Self::own_area(dir)?;
}
- vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display()))
+ vectorcraft_format::write_atomic(&f, bytes).map_err(|e| Self::file_error(&f, &e))
}
fn remove(&self, name: &str) -> std::result::Result<(), String> {
let f = self.file(name)?;
- match std::fs::remove_file(&f) {
- Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", f.display())),
- _ => Ok(()),
- }
+ Self::removed(&f, std::fs::remove_file(&f))
}
fn location(&self) -> String {
self.0.to_string_lossy().into_owned()
@@ -479,6 +540,8 @@ pub struct Recovery {
/// store: each stays until its document has a copy in the new area, or needs none. They are
/// still this session's: never offered or discarded as a crash's here.
left: BTreeMap, String)>,
+ /// The `/keeps-` entries this session wrote in its area ([`mark_left`]).
+ marked: BTreeSet,
}
impl Recovery {
@@ -553,6 +616,15 @@ fn beating(store: &dyn RecoveryStore, area: &str, stale: i64, mine: Option)
/// beat only lets another app offer the copies once three intervals have passed.
pub fn heartbeat(s: &mut Session) -> Vec {
let mut lost = keep_hold(s);
+ // An entry that couldn't be written before, or that was removed since (by the user, a
+ // cleaner), is written now.
+ if let Some(own) = &s.recovery.own
+ && !s.recovery.marked.is_empty()
+ && let Ok(names) = own.store.list()
+ {
+ s.recovery.marked.retain(|n| names.contains(n));
+ }
+ mark_left(s);
if let Some(own) = s.recovery.own.as_ref().filter(|o| o.beats) {
let _ = beat(own.store.as_ref(), &own.area);
}
@@ -622,6 +694,36 @@ fn drop_left(s: &mut Session, uid: u64) {
let meta = store.remove(&format!("{file}{META_EXT}"));
if doc.is_ok() && meta.is_ok() {
s.recovery.left.remove(&uid);
+ mark_left(s);
+ }
+}
+
+/// Record in this session's area each area it left that still holds copies of its documents: an
+/// entry `/keeps-`, so another app leaves those copies alone while this one
+/// runs ([`keeper`]). The entries of areas that hold none of its copies any more go. Best effort:
+/// an entry not written or removed now is written or removed at the next heartbeat.
+fn mark_left(s: &mut Session) {
+ let Some(own) = &s.recovery.own else { return };
+ let (store, area) = (own.store.clone(), own.area.clone());
+ let want: BTreeSet = s
+ .recovery
+ .left
+ .values()
+ .filter(|(st, _)| st.location() == store.location())
+ .filter_map(|(_, f)| f.split_once('/').map(|(a, _)| a))
+ .filter(|a| *a != area)
+ .map(|a| format!("{area}/{KEEPS}{a}"))
+ .collect();
+ let gone: Vec = s.recovery.marked.difference(&want).cloned().collect();
+ for n in gone {
+ if store.remove(&n).is_ok() {
+ s.recovery.marked.remove(&n);
+ }
+ }
+ for n in want {
+ if !s.recovery.marked.contains(&n) && store.write(&n, b"").is_ok() {
+ s.recovery.marked.insert(n);
+ }
}
}
@@ -670,6 +772,9 @@ pub fn claim(s: &mut Session, store: &Arc) -> Result
}
};
s.recovery.own = Some(Own { store: store.clone(), area: area.clone(), hold, beats });
+ // The entries written before are in the area this session had (released or left).
+ s.recovery.marked.clear();
+ mark_left(s);
return Ok(area);
}
Err(EngineError::Other("no free recovery area".into()))
@@ -686,6 +791,8 @@ pub fn release(s: &mut Session) {
drop_left(s, uid);
}
let Some(Own { store, area, hold, .. }) = s.recovery.own.take() else { return };
+ // Its `keeps-` entries go with the area's other entries.
+ s.recovery.marked.clear();
let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area);
for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) {
let _ = store.remove(name);
@@ -931,6 +1038,7 @@ impl RecoveryJob {
let _ = store.remove(&format!("{file}{DOC_EXT}"));
let _ = store.remove(&format!("{file}{META_EXT}"));
}
+ mark_left(s);
}
// Best effort, as in `remove_copy`.
_ => {
@@ -1152,6 +1260,10 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
}
match store.lock(area) {
Ok(Lock::Held(h)) => {
+ // Copies an app left here when it had to leave the area stay its own while it runs.
+ if let Some(who) = keeper(s, store, area) {
+ return who;
+ }
hold.push(h);
Owner::Gone
}
@@ -1160,6 +1272,9 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) {
return Owner::Running;
}
+ if let Some(who) = keeper(s, store, area) {
+ return who;
+ }
// Best effort: without it another app might offer the copies too, which loses nothing.
if take && let Ok(t) = beat(store, area) {
s.recovery.adopted.insert(area.to_string(), t);
@@ -1169,6 +1284,42 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
}
}
+/// Who keeps the copies left in `area` by an app that had to leave it, from the entries
+/// `/keeps- ` ([`mark_left`]): this session, or another app that is running. `None`:
+/// no app that runs keeps them. An entry counts only in an area this account can lock or tell is
+/// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be
+/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing).
+fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option {
+ let Ok(names) = store.list() else { return Some(Owner::Running) };
+ let mark = format!("/{KEEPS}{area}");
+ let mut who = None;
+ for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) {
+ match holder(s, store, k) {
+ Owner::Mine => return Some(Owner::Mine),
+ Owner::Running => who = Some(Owner::Running),
+ Owner::Gone => {}
+ }
+ }
+ who
+}
+
+/// Whose area `area` is, without taking it over: this session's, a running app's, or nobody's (a
+/// lock taken to tell goes at once). An area whose lock fails (not a recovery area of this
+/// account, a lock file that can't be opened) is nobody's.
+fn holder(s: &Session, store: &dyn RecoveryStore, area: &str) -> Owner {
+ if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) {
+ return Owner::Mine;
+ }
+ match store.lock(area) {
+ Ok(Lock::Held(_)) | Err(_) => Owner::Gone,
+ Ok(Lock::Busy) => Owner::Running,
+ Ok(Lock::Unsupported) => {
+ let runs = store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied());
+ if runs { Owner::Running } else { Owner::Gone }
+ }
+ }
+}
+
/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). The
/// area is removed while it is still held, so an app taking a new area of that name meanwhile
/// finds it held or gone and takes the next name.
diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs
index e97659bfc9..4153fedd51 100644
--- a/crates/engine/src/cmd/stroke.rs
+++ b/crates/engine/src/cmd/stroke.rs
@@ -206,11 +206,31 @@ fn no_targets(ids: &[vectorcraft_doc::NodeId], p: &Value) -> bool {
ids.is_empty() && p.get("ids").is_none() && p.get("id").is_none()
}
+/// The objects `stroke.set` edits with no `item`: a selected group or layer with a stroke row of
+/// its own edits that row, as the Stroke panel does with the group targeted (#6739); the other
+/// targets reach their painted leaves, as before.
+fn own_stroke_or_leaves(s: &Session, p: &Value) -> Result> {
+ let picked = super::targets(s, p)?;
+ let d = &s.doc()?.doc;
+ let own = |id: &vectorcraft_doc::NodeId| {
+ d.node(*id).is_some_and(|n| {
+ matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.is_compound_shape() && n.appearance.stroke().is_some()
+ })
+ };
+ let (mut out, rest): (Vec<_>, Vec<_>) = picked.into_iter().partition(own);
+ for id in super::leaf_targets(s, &rest)? {
+ if !out.contains(&id) {
+ out.push(id);
+ }
+ }
+ Ok(out)
+}
+
fn stroke_set(s: &mut Session, p: &Value) -> Result {
const C: &str = "stroke.set";
let change = StrokeChange::parse(p, C)?;
let item = item_target(s, p, C)?.of_kind(s, false);
- let mut ids = item.targets(s, p)?;
+ let mut ids = if item == ItemTarget::Top { own_stroke_or_leaves(s, p)? } else { item.targets(s, p)? };
let new_art = no_targets(&ids, p);
if item == ItemTarget::Top {
// Images and symbol instances take no stroke from the panel.
@@ -261,7 +281,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result {
return Ok(());
}
if index.is_none() && n.appearance.stroke().is_none() {
- n.appearance.set_stroke(Paint::solid(Color::BLACK));
+ // Options alone (cap, join, miter limit, dashes, alignment) don't make a stroke
+ // visible: the row made for them has no paint. A weight, an arrowhead or a profile
+ // still gives the object the black stroke the panel draws (#6739).
+ let visible = change.weight.is_some() || sa.is_some() || ea.is_some() || profile.is_some();
+ n.appearance.set_stroke(if visible { Paint::solid(Color::BLACK) } else { Paint::None });
}
if let Some(st) = n.appearance.stroke_at_mut(index) {
set(st);
diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs
index 45320066cf..8101cfff97 100644
--- a/crates/engine/src/cmd/swatchlib.rs
+++ b/crates/engine/src/cmd/swatchlib.rs
@@ -85,7 +85,7 @@ pub fn specs() -> Vec {
"Copy to User Defined",
["Window", "Swatch Libraries"],
None,
- "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}",
+ "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}",
has_user_folder,
copy_to_user
),
@@ -368,7 +368,9 @@ impl Libraries {
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
Some(p) => {
- // Outside the request's read roots: the error names no path.
+ // The file's bytes are read: outside the request's read roots, the error names no
+ // path. A document's swatches (the arm below) are written from the loaded library,
+ // and the document is not read again.
if !readable(&p) {
return Err(bad(cmd, "the library's file is outside the read roots"));
}
@@ -469,19 +471,34 @@ fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string())
}
+/// The file name a concealed library or path is shown by: the last part of `path` as this platform
+/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute
+/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one
+/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part.
+pub fn concealed_file_name(path: &str) -> String {
+ let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten();
+ native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned())
+}
+
/// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without
-/// roots in force.
+/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is
+/// never readable under a read root.
fn readable(path: &str) -> bool {
- crate::file_access::check_read(path).is_ok()
+ !crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok()
}
/// The loaded library ids (`loaded/`) whose file the request being served may not read, each
/// with the id a reply shows instead: `loaded/`, as a library loaded without a path is
-/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then
-/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free
-/// one of `loaded/ (2)`, `(3)` and so on is shown, so that two libraries stay two. The
-/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows
-/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none.
+/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute
+/// path counts as one the request may not read. The ids are those of the
+/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers
+/// to). When another id already is or shows that id, the next free
+/// one of `loaded/ (2)`, `(3)` and so on is shown, so that two libraries stay two. A library
+/// keeps the id it was first shown under for the rest of the session, so an id a client was given
+/// finds the same library after another one with the same file name is loaded; only a library that
+/// shows that id itself (one loaded later without a file under that name) moves it to the next free
+/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of
+/// their full ids, then `others`, in the same order. Without roots in force, none.
pub fn concealed_library_ids(s: &Session, others: impl IntoIterator- ) -> Vec<(String, String)> {
if crate::file_access::current().is_none() {
return vec![];
@@ -493,7 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator
- hidden.push((id, name)),
_ => {
@@ -502,16 +519,32 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator
- = assigned.values().cloned().collect();
hidden
.into_iter()
- .map(|(id, name)| {
- let mut shown = format!("loaded/{name}");
- let mut n = 2;
- while taken.contains(&shown) {
- shown = format!("loaded/{name} ({n})");
- n += 1;
- }
- taken.insert(shown.clone());
+ .zip(kept)
+ .map(|((id, name), kept)| {
+ let shown = kept.unwrap_or_else(|| {
+ let mut shown = format!("loaded/{name}");
+ let mut n = 2;
+ while taken.contains(&shown) || given.contains(&shown) {
+ shown = format!("loaded/{name} ({n})");
+ n += 1;
+ }
+ taken.insert(shown.clone());
+ given.insert(shown.clone());
+ assigned.insert(id.clone(), shown.clone());
+ shown
+ });
(id, shown)
})
.collect()
@@ -746,8 +779,12 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result
{
"user" => (info, lib, false),
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
};
- let path = s.swatch_libraries.shown_path(&info.id);
- Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
+ let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied});
+ // As the saves with `user: true`: no `path` when the request may not read the file.
+ if let Some(path) = s.swatch_libraries.shown_path(&info.id) {
+ out["path"] = json!(path);
+ }
+ Ok(out)
}
/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`:
diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs
index 331227e3be..04a2c5504a 100644
--- a/crates/engine/src/cmd/tabs.rs
+++ b/crates/engine/src/cmd/tabs.rs
@@ -13,7 +13,7 @@ pub fn specs() -> Vec {
"Set Tab Stops",
["Window", "Tabs"],
None,
- "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); supplied align, leader and alignOn must be strings, and alignOn cannot be empty",
+ "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept",
has_selection,
set
),
@@ -38,10 +38,14 @@ pub fn specs() -> Vec {
fn parse_stops(p: &Value) -> Result> {
const C: &str = "text.tabs.set";
let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?;
+ let max = TabStop::MAX_STOPS;
+ if arr.len() > max {
+ return Err(bad(C, format!("at most {max} tab stops")));
+ }
let mut stops = vec![];
for s in arr {
let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?;
- if !(0.0..=100_000.0).contains(&position) {
+ if !(0.0..=TabStop::MAX_POSITION).contains(&position) {
return Err(bad(C, "positions must be between 0 and 100000 pt"));
}
let align = match s.get("align") {
@@ -60,16 +64,25 @@ fn parse_stops(p: &Value) -> Result> {
None | Some(Value::Null) => "",
Some(Value::String(value)) => value.as_str(),
Some(_) => return Err(bad(C, "`leader` must be a string")),
+ };
+ let (label, max) = ("`leader`", TabStop::MAX_LEADER);
+ if leader.chars().count() > max {
+ return Err(bad(C, format!("{label} must be at most {max} characters")));
}
- .chars()
- .take(8)
- .collect();
let align_on = match s.get("alignOn") {
None | Some(Value::Null) => '.',
- Some(Value::String(value)) => value.chars().next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?,
+ Some(Value::String(value)) => {
+ let mut chars = value.chars();
+ let c = chars.next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?;
+ if chars.next().is_some() {
+ let label = "`alignOn`";
+ return Err(bad(C, format!("{label} must be one character")));
+ }
+ c
+ }
Some(_) => return Err(bad(C, "`alignOn` must be a string")),
};
- stops.push(TabStop { position, align, leader, align_on });
+ stops.push(TabStop { position, align, leader: leader.to_string(), align_on });
}
stops.sort_by(|a, b| a.position.total_cmp(&b.position));
stops.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6);
@@ -128,7 +141,7 @@ fn get(s: &mut Session, p: &Value) -> Result {
#[cfg(test)]
mod tests {
- use serde_json::json;
+ use serde_json::{Value, json};
use crate::Session;
@@ -139,6 +152,21 @@ mod tests {
}
}
+ /// A leader of more than eight characters, an `alignOn` of more than one character and a list
+ /// of more than 256 stops are errors, not cut short.
+ #[test]
+ fn long_leaders_long_align_on_and_too_many_stops_are_errors() {
+ let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string();
+ assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters"));
+ assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character"));
+ let many: Vec = (0..257).map(|i| json!({"position": i})).collect();
+ assert!(err(Value::from(many.clone())).contains("at most 256 tab stops"));
+ let ok = super::parse_stops(&json!({ "stops": &many[..256] })).unwrap();
+ assert_eq!(ok.len(), 256);
+ let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap();
+ assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€'));
+ }
+
#[test]
fn set_get_clear_tabs() {
let mut s = Session::new();
diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs
index d30e69df68..dc424e1157 100644
--- a/crates/engine/src/cmd/textstyles.rs
+++ b/crates/engine/src/cmd/textstyles.rs
@@ -8,7 +8,7 @@
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value, json};
-use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef};
+use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TabStop, TextStyleDef};
use super::clipboard::resources::import_text_styles;
use super::typecmd::{refresh_bounds, text_targets};
@@ -125,7 +125,7 @@ macro_rules! style_cmds {
}
/// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`.
-const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind";
+const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new or paraStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind";
pub fn specs() -> Vec {
let mut v = style_cmds!(
@@ -148,13 +148,13 @@ pub fn specs() -> Vec {
"paraStyle",
"Paragraph Styles",
"Paragraph",
- "; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt"
+ "; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position"
));
v.push(spec(
"paraStyle.edit",
"Edit Paragraph Style",
&[],
- "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt",
+ "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position",
|s, p| edit_style(s, p, Kind::Para),
true,
));
@@ -569,8 +569,9 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
char_attrs(&st)
}
Kind::Para => {
- let st = with_attrs(&ParaStyle::default(), m, &c)?;
+ let mut st = with_attrs(&ParaStyle::default(), m, &c)?;
para_ranges(&st, m, kept, &c)?;
+ sort_tabs(&mut st, m, kept);
para_attrs(&st)
}
};
@@ -587,7 +588,8 @@ const PARA_DISTANCE: f64 = 1296.0;
/// Check the paragraph attributes of `st` (read from `attrs`) that differ from `kept` against the
/// ranges `text.setFormat` keeps them to: the indents and the space before and after, each
/// Hyphenation option, each Justification option (Minimum, Desired and Maximum in order, as
-/// `attrs` gives them) and the position of each tab stop. A value `kept` has is not checked.
+/// `attrs` gives them), and the number of tab stops with the position and leader of each stop
+/// `kept` does not hold. A value `kept` has is not checked.
fn para_ranges(st: &ParaStyle, attrs: &Map, kept: Option<&Map>, c: &str) -> Result<()> {
let within = |key: &str, value: f64, lo: f64, hi: f64| {
if (lo..=hi).contains(&value) { Ok(()) } else { Err(bad(c, format!("`{key}` must be from {lo} to {hi}"))) }
@@ -644,18 +646,37 @@ fn para_ranges(st: &ParaStyle, attrs: &Map, kept: Option<&Map = kept
- .and_then(|k| k.get("tabs"))
- .and_then(Value::as_array)
- .map(|stops| stops.iter().filter_map(|s| s.get("position").and_then(Value::as_f64)).collect())
- .unwrap_or_default();
- for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(&stop.position)) {
- within("tabs.position", stop.position, 0.0, 100_000.0)?;
+ let kept_tabs = kept_tab_stops(kept);
+ let max = TabStop::MAX_STOPS;
+ if st.tabs.len() > max.max(kept_tabs.len()) {
+ return Err(bad(c, format!("at most {max} tab stops")));
+ }
+ for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(stop)) {
+ within("tabs.position", stop.position, 0.0, TabStop::MAX_POSITION)?;
+ let (label, max) = ("`tabs.leader`", TabStop::MAX_LEADER);
+ if stop.leader.chars().count() > max {
+ return Err(bad(c, format!("{label} must be at most {max} characters")));
+ }
}
}
Ok(())
}
+/// The tab stops `kept` holds, each as it reads (a stop that does not read is left out).
+fn kept_tab_stops(kept: Option<&Map>) -> Vec {
+ let stops = kept.and_then(|k| k.get("tabs")).and_then(Value::as_array);
+ stops.into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect()
+}
+
+/// `st`'s tab stops sorted by position, keeping the first of stops within 1e-6 pt of each other,
+/// as `text.tabs.set` keeps them, when `attrs` changes them from `kept`.
+fn sort_tabs(st: &mut ParaStyle, attrs: &Map, kept: Option<&Map>) {
+ if attrs.contains_key("tabs") && kept.and_then(|k| k.get("tabs")) != attrs.get("tabs") {
+ st.tabs.sort_by(|a, b| a.position.total_cmp(&b.position));
+ st.tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6);
+ }
+}
+
/// Check a character style's `fill` or `stroke` (`key`): each solid color and gradient stop has a
/// tint from 0 to 1, and a link to a global swatch of `d`, whose color at that tint it takes.
fn style_paint(d: &Document, paint: &mut vectorcraft_color::Paint, key: &str, c: &str) -> Result<()> {
@@ -1011,10 +1032,29 @@ fn unused(s: &mut Session, kind: Kind) -> Result {
/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps
/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes,
-/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, or whose name
-/// is empty, which are skipped. → (the styles, the names in `src` of those skipped)
+/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, whose
+/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range
+/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in
+/// `src` of those skipped)
fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) {
- const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"];
+ const RANGED: [&str; 16] = [
+ "size",
+ "h_scale",
+ "v_scale",
+ "leading",
+ "baseline_shift",
+ "tcyUpDown",
+ "tcyLeftRight",
+ "stroke_width",
+ "left_indent",
+ "right_indent",
+ "first_line_indent",
+ "space_before",
+ "space_after",
+ "hyphenation",
+ "justification",
+ "tabs",
+ ];
let (mut defs, mut skipped) = (vec![], vec![]);
for def in kind.defs(src) {
let name = def.name.as_str();
diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs
index 87bca29048..e38798401e 100644
--- a/crates/engine/src/cmd/textstyles/tests.rs
+++ b/crates/engine/src/cmd/textstyles/tests.rs
@@ -1729,3 +1729,77 @@ fn paragraph_style_attributes_are_checked_before_a_definition_changes() {
assert!(error.contains(&format!("`{key}`")), "{attrs}: {error}");
}
}
+
+/// A paragraph style's changed tab stops are checked stop by stop: a stop the style holds is kept
+/// as it is, and a stop at a held position with another alignment is a changed stop. A changed
+/// stop's leader has at most eight characters and its Align On one character, a changed list has
+/// at most 256 stops (or as many as the style holds, here 300), and the stops are stored sorted by position,
+/// keeping the first of stops at one position.
+#[test]
+fn paragraph_style_tab_stops_are_checked_by_stop_and_sorted() {
+ let (mut s, _) = session_with_text("Text");
+ s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap();
+ let def = |s: &Session| s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Body").unwrap().attrs.clone();
+ let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
+ let held: Vec =
+ (0..300).map(|i| json!({"position": 200000.0 + f64::from(i), "align": "left", "leader": "123456789", "alignOn": "."})).collect();
+ doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), Value::from(held.clone()));
+ let before = s.doc().unwrap().doc.clone();
+ let mut moved = held.clone();
+ moved[0]["align"] = json!("right");
+ for (stops, why) in [
+ (Value::from(moved), "`tabs.position`"),
+ (json!([{"position": 36.0, "leader": "123456789"}]), "`tabs.leader` must be at most 8 characters"),
+ (Value::from((0..301).map(|i| json!({"position": f64::from(i)})).collect::>()), "at most 256 tab stops"),
+ (json!([{"position": 36.0, "alignOn": ",."}]), "expected a character"),
+ ] {
+ for cmd in ["paraStyle.edit", "paraStyle.setAttrs"] {
+ let params = match cmd {
+ "paraStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": {"tabs": stops}}),
+ _ => json!({"name": "Body", "attrs": {"tabs": stops}}),
+ };
+ let error = s.execute(cmd, ¶ms).unwrap_err().to_string();
+ assert!(error.contains(why), "{cmd}: {error}");
+ }
+ }
+ let many = Value::from((0..257).map(|i| json!({"position": f64::from(i)})).collect::>());
+ let error = s.execute("paraStyle.new", &json!({"name": "Other", "attrs": {"tabs": many}})).unwrap_err().to_string();
+ assert!(error.contains("at most 256 tab stops"), "{error}");
+ assert_eq!(s.doc().unwrap().doc, before);
+ // The held stops stay while one is dropped and another attribute changes.
+ s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": &held[1..], "left_indent": 4.0}})).unwrap();
+ assert_eq!(def(&s)["tabs"].as_array().map(Vec::len), Some(299));
+ let stops = json!([{"position": 72.0, "align": "right"}, {"position": 36.0}, {"position": 36.0000001, "align": "center"}, {"position": 0.0, "leader": "12345678"}]);
+ s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap();
+ assert_eq!(
+ def(&s)["tabs"],
+ json!([{"position": 0.0, "align": "left", "leader": "12345678", "alignOn": "."}, {"position": 36.0, "align": "left", "alignOn": "."},
+ {"position": 72.0, "align": "right", "alignOn": "."}])
+ );
+ s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": []}})).unwrap();
+ assert_eq!(def(&s)["tabs"], json!([]), "an empty list stays set");
+}
+
+/// Load Paragraph Styles and Load All Styles skip a paragraph style whose indents, spacing,
+/// Hyphenation or Justification options or tab stops are out of the range `paraStyle.new` takes,
+/// and load the others with their stops.
+#[test]
+fn loading_skips_paragraph_styles_out_of_range() {
+ let file = [
+ ("Good", json!({"left_indent": 6.0, "tabs": [{"position": 36.0, "align": "decimal", "alignOn": ","}]})),
+ ("Indent", json!({"left_indent": 2000.0})),
+ ("Space", json!({"space_after": -1300.0})),
+ ("Hyphens", json!({"hyphenation": {"words_longer_than": 40}})),
+ ("Spacing", json!({"justification": {"word_spacing": [120.0, 100.0, 133.0]}})),
+ ("Leader", json!({"tabs": [{"position": 10.0, "leader": "123456789"}]})),
+ ];
+ let data = styles_file(&[], &file);
+ for cmd in ["paraStyle.load", "textStyle.loadAll"] {
+ let (mut s, _) = session_with_text("Title");
+ let r = s.execute(cmd, &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
+ let r = if cmd == "textStyle.loadAll" { r["paraStyles"].clone() } else { r };
+ assert_eq!(r, json!({"added": ["Good"], "existing": [], "skipped": ["Indent", "Space", "Hyphens", "Spacing", "Leader"]}), "{cmd}");
+ let good = super::style_attrs(&s.doc().unwrap().doc, true, "Good").unwrap();
+ assert_eq!(good["tabs"], json!([{"position": 36.0, "align": "decimal", "alignOn": ","}]), "{cmd}");
+ }
+}
diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs
index 73c8ba8efe..6ca724d810 100644
--- a/crates/engine/src/file_access.rs
+++ b/crates/engine/src/file_access.rs
@@ -215,6 +215,20 @@ pub fn check_read(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Read))
}
+/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one
+/// that starts with a drive (`C:`) or a `\`? [`check_read`] reads it as a name in the working folder.
+pub fn written_elsewhere(path: &str) -> bool {
+ let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic());
+ !cfg!(windows) && foreign && current().is_some()
+}
+
+/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`,
+/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`,
+/// `destinationFolder`)?
+pub fn is_path_key(key: &str) -> bool {
+ matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end))
+}
+
/// 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))?;
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 745a6fb62c..b924a52e86 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -151,6 +151,9 @@ pub struct Session {
pub symbol_libraries: cmd::symbollib::Libraries,
/// User Defined and loaded brush libraries (Window → Brush Libraries); not saved.
pub brush_libraries: cmd::brushlib::Libraries,
+ /// The id each loaded library outside a request's read roots has been shown under, by its own id
+ /// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session.
+ shown_library_ids: std::sync::Mutex>,
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
pub libraries: cmd::library::Libraries,
/// Where the saved PDF presets are kept, one file each; `None`: only for the session
@@ -302,6 +305,8 @@ pub struct Session {
pub(crate) press_point: Option>,
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
pub(crate) ruler_guide: Option,
+ /// The rulers' origin being dragged out of their corner ([`Session::ruler_origin_drag`]).
+ pub(crate) ruler_origin: Option,
/// Which selected type overflows its frame, for the ports every tool but the Type tools shows.
pub(crate) port_marks: vectorcraft_tools::text::PortMarks,
/// The last search for the files of missing fonts (`text.findFontFiles`), until another
diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs
index 2d850fd44d..c5ba0410bf 100644
--- a/crates/engine/src/session/documents.rs
+++ b/crates/engine/src/session/documents.rs
@@ -80,6 +80,7 @@ impl Session {
style_libraries: Default::default(),
symbol_libraries: Default::default(),
brush_libraries: Default::default(),
+ shown_library_ids: Default::default(),
libraries: Default::default(),
pdf_preset_dir: None,
dictionary_file: None,
@@ -123,6 +124,7 @@ impl Session {
press_type_selection: None,
press_point: None,
ruler_guide: None,
+ ruler_origin: None,
port_marks: Default::default(),
font_search: None,
link_search: None,
diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs
index abe9121eff..23079fe901 100644
--- a/crates/engine/src/session/execute.rs
+++ b/crates/engine/src/session/execute.rs
@@ -82,7 +82,13 @@ impl Session {
&& params.get("ids").is_none_or(Value::is_null)
&& params.get("id").and_then(Value::as_str).is_some_and(|e| !e.starts_with('$'));
if cmd::params::id_stands_for_ids(spec.params) && !effect_named && self.active().is_some() {
- cmd::given_objects(self, params, id)?;
+ // `effect.refreshDeferred` looks its objects up itself: they can also be in the art of
+ // a symbol, pattern, brush or opacity mask.
+ if id == "effect.refreshDeferred" {
+ cmd::given_object_ids(params, id)?;
+ } else {
+ cmd::given_objects(self, params, id)?;
+ }
}
if SELECTS_GIVEN_IDS.contains(&id) {
self.check_given_ids(id, params)?;
diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs
index 8d12c83719..53cbb42ca9 100644
--- a/crates/engine/src/tests/appearance.rs
+++ b/crates/engine/src/tests/appearance.rs
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
-use vectorcraft_doc::{AppearanceItem, FillLayer, Node};
+use vectorcraft_doc::{AppearanceItem, FillLayer, LineJoin, Node};
use crate::*;
@@ -185,6 +185,48 @@ fn group_rows_are_the_groups_own_stack() {
assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00");
}
+/// #6739: `stroke.set` without an item on a group with a stroke row of its own edits that row;
+/// its members, which have no stroke (as paths a PDF import brings in), are left alone instead of
+/// each getting a black 1 pt stroke. Options alone never make a stroke visible: a row created for
+/// a join has no paint, while a weight still gives the usual black stroke.
+#[test]
+fn stroke_options_on_a_group_with_its_own_stroke_edit_that_stroke() {
+ let mut s = session();
+ let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 250.0));
+ for id in [a, b] {
+ run(&mut s, "appearance.removeItem", json!({"ids": [id.0], "index": 1}));
+ assert!(node(&s, id).appearance.stroke().is_none(), "the member has no stroke row");
+ }
+ let g = NodeId(run(&mut s, "object.group", json!({"ids": [a.0, b.0]}))["id"].as_u64().unwrap());
+ run(&mut s, "appearance.addStroke", json!({"ids": [g.0]}));
+ run(&mut s, "appearance.setItem", json!({"ids": [g.0], "index": 0, "color": "#ffffff", "weight": 2}));
+ let r = run(&mut s, "stroke.set", json!({"ids": [g.0], "join": "round"}));
+ assert_eq!(r["objects"], 1, "{r}");
+ let gs = node(&s, g).appearance.stroke().unwrap().clone();
+ assert_eq!((gs.join, gs.width, gs.paint.color().map(|c| c.to_hex())), (LineJoin::Round, 2.0, Some("#ffffff".to_string())));
+ for id in [a, b] {
+ assert!(node(&s, id).appearance.stroke().is_none(), "a member keeps no stroke");
+ }
+ // A group without a stroke of its own still reaches its members; for a join alone the row
+ // made on each has no paint, so nothing becomes visible.
+ let (c, d) = (rect(&mut s, 400.0), rect(&mut s, 550.0));
+ for id in [c, d] {
+ run(&mut s, "appearance.removeItem", json!({"ids": [id.0], "index": 1}));
+ }
+ let h = NodeId(run(&mut s, "object.group", json!({"ids": [c.0, d.0]}))["id"].as_u64().unwrap());
+ run(&mut s, "stroke.set", json!({"ids": [h.0], "join": "bevel"}));
+ for id in [c, d] {
+ let st = node(&s, id).appearance.stroke().cloned().expect("a stroke row for the join");
+ assert_eq!((st.paint, st.join), (Paint::None, LineJoin::Bevel), "no paint for a join alone");
+ }
+ // A weight is a visible stroke: an object without one gets the black stroke, as before.
+ let e = rect(&mut s, 700.0);
+ run(&mut s, "appearance.removeItem", json!({"ids": [e.0], "index": 1}));
+ run(&mut s, "stroke.set", json!({"ids": [e.0], "weight": 3}));
+ let st = node(&s, e).appearance.stroke().cloned().unwrap();
+ assert_eq!((st.paint, st.width), (Paint::solid(Color::BLACK), 3.0));
+}
+
#[test]
fn text_items_take_item_paint() {
let mut s = session();
diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs
index fd1ac70476..1a1fcd87b5 100644
--- a/crates/engine/src/tests/brushsym.rs
+++ b/crates/engine/src/tests/brushsym.rs
@@ -253,6 +253,367 @@ fn selection_art_is_the_art_new_brush_takes() {
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "only brush.new is an undo step");
}
+fn undo_steps(s: &Session) -> usize {
+ s.doc().unwrap().history.undo.len()
+}
+
+fn selected(s: &Session) -> Vec {
+ s.doc().unwrap().selection.objects.clone()
+}
+
+/// The JSON of object `id`, as `brush.options` params take art.
+fn art_json(s: &mut Session, id: NodeId) -> Value {
+ s.execute("brush.selectionArt", &json!({"ids": [id.0]})).unwrap()["art"].clone()
+}
+
+/// `brush.placeArt` places a copy of an art or scatter brush's art centered where it is dropped
+/// (by default on the first artboard's center), selected, in one undo step; brushes without art,
+/// unknown brushes and a position that is not a number are refused.
+#[test]
+fn place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped() {
+ let mut s = session();
+ let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0);
+ s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap();
+ let steps = undo_steps(&s);
+ let id = NodeId(s.execute("brush.placeArt", &json!({"name": "Bar", "x": 100, "y": 80})).unwrap()["id"].as_u64().unwrap());
+ assert_eq!(undo_steps(&s), steps + 1);
+ assert_eq!(selected(&s), vec![id]);
+ assert_ne!(id, r, "a copy, not the art the brush was made from");
+ assert!(close(bounds(&s, id), Rect::new(80.0, 76.0, 120.0, 84.0), 1e-6), "{:?}", bounds(&s, id));
+ // Without a position: the first artboard's center (the document is 400 × 300).
+ let scatter = NodeId(s.execute("brush.placeArt", &json!({"name": "Dots"})).unwrap()["id"].as_u64().unwrap());
+ let c = bounds(&s, scatter).center();
+ assert!((c.x - 200.0).abs() < 1e-6 && (c.y - 150.0).abs() < 1e-6, "{c:?}");
+ let steps = undo_steps(&s);
+ for (p, msg) in [
+ (json!({"name": "3 pt. Round"}), "`3 pt. Round` has no art (only scatter, art and pattern brushes do)"),
+ (json!({"name": "Bristle Round"}), "`Bristle Round` has no art (only scatter, art and pattern brushes do)"),
+ (json!({"name": "Nope"}), "no brush named `Nope`"),
+ ] {
+ let e = s.execute("brush.placeArt", &p).unwrap_err();
+ assert!(e.to_string().contains(msg), "{p}: {e}");
+ }
+ assert!(s.execute("brush.placeArt", &json!({"name": "Bar", "x": "100"})).is_err());
+ assert_eq!(undo_steps(&s), steps, "a refused call adds no undo step");
+}
+
+/// Brush art holding an instance of a symbol whose art lies away from the symbol's origin (as
+/// imported symbols can) is centered by that art, not by the box an instance has without its
+/// document.
+#[test]
+fn placed_art_with_a_symbol_instance_is_centered_by_the_symbols_art() {
+ let mut s = session();
+ let r = rect(&mut s, 300.0, 10.0, 100.0, 40.0);
+ let art = node(&s, r);
+ s.execute("edit.clear", &json!({"ids": [r.0]})).unwrap();
+ let st = s.doc_mut().unwrap();
+ let d = std::sync::Arc::make_mut(&mut st.doc);
+ d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Off".into(), art: std::sync::Arc::new(art) });
+ let layer = d.layers[0].id;
+ let inst = d.alloc_id();
+ let xf = vectorcraft_geom::Affine::translate((50.0, 50.0));
+ d.insert(Some(layer), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Off".into(), xf })).unwrap();
+ let inst = inst.0;
+ s.execute("brush.new", &json!({"type": "art", "name": "Stamp", "ids": [inst]})).unwrap();
+ let id = NodeId(s.execute("brush.placeArt", &json!({"name": "Stamp", "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap());
+ let st = s.doc().unwrap();
+ let b = st.doc.bounds_of(&[id], false).unwrap();
+ assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 150.0).abs() < 1e-6, "{b:?}");
+ assert!((b.width() - 100.0).abs() < 1e-6, "{b:?}");
+}
+
+/// A pattern brush with side, outer corner and start tiles, the side tile with a repeat frame
+/// wider than its art: → its name.
+fn framed_pattern_brush(s: &mut Session) -> String {
+ let side = rect(s, 0.0, 0.0, 20.0, 10.0);
+ let corner = rect(s, 0.0, 40.0, 10.0, 10.0);
+ let start = rect(s, 0.0, 80.0, 6.0, 10.0);
+ let name = s.execute("brush.new", &json!({"type": "pattern", "name": "Rail", "ids": [side.0]})).unwrap()["name"].as_str().unwrap().to_string();
+ let params = json!({
+ "side": art_json(s, side),
+ "side_frame": Rect::new(-2.0, 0.0, 22.0, 10.0),
+ "outer_corner": art_json(s, corner),
+ "start": art_json(s, start),
+ });
+ s.execute("brush.options", &json!({"name": name, "params": params})).unwrap();
+ name
+}
+
+/// `brush.placeArt` lays a pattern brush's tiles out left to right, each in a group named for its
+/// tile, with an unpainted rectangle at the back of a tile with a repeat frame marking that frame.
+#[test]
+fn pattern_brushes_place_their_tiles_in_a_row() {
+ let mut s = session();
+ let name = framed_pattern_brush(&mut s);
+ let id = NodeId(s.execute("brush.placeArt", &json!({"name": name, "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap());
+ let placed = node(&s, id);
+ let tiles: Vec = placed.children().unwrap().iter().map(|c| (**c).clone()).collect();
+ let names: Vec<&str> = tiles.iter().map(|t| t.name.as_deref().unwrap_or("")).collect();
+ assert_eq!(names, ["Side Tile", "Outer Corner Tile", "Start Tile"]);
+ let boxes: Vec = tiles.iter().map(|t| t.geometric_bounds().unwrap()).collect();
+ assert!(boxes.windows(2).all(|w| (w[1].x0 - w[0].x1 - 10.0).abs() < 1e-6), "10 pt apart, left to right: {boxes:?}");
+ // The side tile: the frame's rectangle, unpainted, behind the art.
+ let side = tiles[0].children().unwrap();
+ assert_eq!(side.len(), 2);
+ let marker = &side[0];
+ assert!(marker.appearance.items.iter().all(|i| match i {
+ vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.is_none(),
+ vectorcraft_doc::AppearanceItem::Stroke(st) => st.paint.is_none(),
+ }));
+ let frame = marker.geometric_bounds().unwrap();
+ assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.height() - 10.0).abs() < 1e-6, "{frame:?}");
+ assert_eq!(tiles[1].children().unwrap().len(), 1, "no frame, no rectangle");
+}
+
+/// `brush.replaceArt` reads the tiles `brush.placeArt` places back tile by tile, frames included:
+/// every tile and the frame keep their sizes, and the frame keeps its place around the art.
+#[test]
+fn placed_pattern_tiles_go_back_into_the_brush_tile_by_tile() {
+ let mut s = session();
+ let name = framed_pattern_brush(&mut s);
+ let before = s.execute("brush.get", &json!({"name": name})).unwrap();
+ let id = NodeId(s.execute("brush.placeArt", &json!({"name": name, "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap());
+ let steps = undo_steps(&s);
+ s.execute("brush.replaceArt", &json!({"name": name, "ids": [id.0]})).unwrap();
+ assert_eq!(undo_steps(&s), steps + 1);
+ let after = s.execute("brush.get", &json!({"name": name})).unwrap();
+ let size = |v: &Value| {
+ let n: Node = serde_json::from_value(v.clone()).unwrap();
+ let b = n.geometric_bounds().unwrap();
+ (b.width(), b.height())
+ };
+ for key in ["side", "outer_corner", "start"] {
+ let (a, b) = (size(&before[key]), size(&after[key]));
+ assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "{key}: {a:?} → {b:?}");
+ }
+ assert!(after.get("end").is_none_or(Value::is_null) && after.get("inner_corner").is_none_or(Value::is_null));
+ let frame: Rect = serde_json::from_value(after["side_frame"].clone()).unwrap();
+ let art: Node = serde_json::from_value(after["side"].clone()).unwrap();
+ let art = art.geometric_bounds().unwrap();
+ assert!((frame.width() - 24.0).abs() < 1e-6 && (art.x0 - frame.x0 - 2.0).abs() < 1e-6, "{frame:?} around {art:?}");
+ assert!(after["outer_corner_frame"].is_null() || after.get("outer_corner_frame").is_none());
+}
+
+/// Art that is not the tiles `brush.placeArt` places becomes a pattern brush's side tile, and the
+/// other tiles stay; one tile group alone replaces only that tile.
+#[test]
+fn other_art_replaces_the_side_tile_of_a_pattern_brush() {
+ let mut s = session();
+ let name = framed_pattern_brush(&mut s);
+ let before = s.execute("brush.get", &json!({"name": name})).unwrap();
+ let r = rect(&mut s, 100.0, 100.0, 30.0, 12.0);
+ s.execute("brush.replaceArt", &json!({"name": name, "ids": [r.0]})).unwrap();
+ let after = s.execute("brush.get", &json!({"name": name})).unwrap();
+ let side: Node = serde_json::from_value(after["side"].clone()).unwrap();
+ assert!(close(side.geometric_bounds().unwrap(), Rect::new(100.0, 100.0, 130.0, 112.0), 1e-6));
+ assert!(after.get("side_frame").is_none_or(Value::is_null), "the old frame goes with the old art");
+ assert_eq!((&after["outer_corner"], &after["start"]), (&before["outer_corner"], &before["start"]));
+ // One placed tile, its group dragged back alone: only the start tile changes.
+ let id = NodeId(s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap());
+ let start = node(&s, id).children().unwrap()[2].clone();
+ assert_eq!(start.name.as_deref(), Some("Start Tile"));
+ s.execute("brush.replaceArt", &json!({"name": name, "ids": [start.id.0]})).unwrap();
+ let last = s.execute("brush.get", &json!({"name": name})).unwrap();
+ assert_eq!((&last["side"], &last["outer_corner"]), (&after["side"], &after["outer_corner"]));
+ let art: Node = serde_json::from_value(last["start"].clone()).unwrap();
+ assert!(close(art.geometric_bounds().unwrap(), start.geometric_bounds().unwrap(), 1e-6), "the start tile is the placed one");
+ assert!(art.name.is_none(), "the tile's name stays on the canvas");
+}
+
+/// `brush.replaceArt` gives a brush new art in one undo step: strokes that use it redraw with it,
+/// or with `applyToStrokes: false` keep the brush as it was while a copy with the new art becomes
+/// the current brush. A brush no stroke uses changes in place either way.
+#[test]
+fn replace_art_applies_to_strokes_or_leaves_them_on_the_old_brush() {
+ let mut s = session();
+ let r = rect(&mut s, 10.0, 200.0, 40.0, 8.0);
+ s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap();
+ let path = line(&mut s);
+ s.execute("brush.apply", &json!({"name": "Bar", "ids": [path.0]})).unwrap();
+ let thin = brushed_bounds(&s, path).height();
+ // A path painted with a brush made from the taller art: what the stroke should draw.
+ let tall = rect(&mut s, 10.0, 220.0, 40.0, 20.0);
+ s.execute("brush.new", &json!({"type": "art", "name": "Tall", "ids": [tall.0]})).unwrap();
+ let reference = line(&mut s);
+ s.execute("brush.apply", &json!({"name": "Tall", "ids": [reference.0]})).unwrap();
+ let want = brushed_bounds(&s, reference).height();
+ assert!(want > thin * 2.0, "{want} against {thin}");
+ // Apply to Strokes: the stroke redraws with the taller art.
+ let steps = undo_steps(&s);
+ let r = s.execute("brush.replaceArt", &json!({"name": "Bar", "ids": [tall.0]})).unwrap();
+ assert_eq!(r["name"], "Bar");
+ assert_eq!(undo_steps(&s), steps + 1);
+ assert!((brushed_bounds(&s, path).height() - want).abs() < 1e-6);
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert!((brushed_bounds(&s, path).height() - thin).abs() < 1e-9, "undo puts the old art back");
+ s.execute("edit.redo", &json!({})).unwrap();
+ // Leave Strokes: the stroke keeps Bar as it was; Bar copy has the new art, after Bar, current.
+ let short = rect(&mut s, 10.0, 260.0, 40.0, 4.0);
+ let bar = s.execute("brush.get", &json!({"name": "Bar"})).unwrap();
+ let r = s.execute("brush.replaceArt", &json!({"name": "Bar", "ids": [short.0], "applyToStrokes": false})).unwrap();
+ assert_eq!(r["name"], "Bar copy");
+ assert_eq!(brush_of(&s, path).as_deref(), Some("Bar"));
+ assert_eq!(s.execute("brush.get", &json!({"name": "Bar"})).unwrap(), bar);
+ assert!((brushed_bounds(&s, path).height() - want).abs() < 1e-6);
+ let copy: Node = serde_json::from_value(s.execute("brush.get", &json!({"name": "Bar copy"})).unwrap()["art"].clone()).unwrap();
+ assert!((copy.geometric_bounds().unwrap().height() - 4.0).abs() < 1e-6);
+ let list = s.execute("brush.list", &json!({})).unwrap();
+ assert_eq!(list["current"], "Bar copy");
+ let names: Vec<&str> = list["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect();
+ let at = names.iter().position(|n| *n == "Bar").unwrap();
+ assert_eq!(names.get(at + 1), Some(&"Bar copy"));
+ // No stroke uses Bar copy: its art changes in place, with or without applying.
+ s.execute("brush.replaceArt", &json!({"name": "Bar copy", "ids": [tall.0], "applyToStrokes": false})).unwrap();
+ assert!(s.execute("brush.get", &json!({"name": "Bar copy 2"})).is_err());
+}
+
+/// `brush.replaceArt` refuses a brush without art, an unknown brush, an empty selection, art with no
+/// width and an `applyToStrokes` that is not a boolean, and changes nothing then.
+#[test]
+fn replace_art_refuses_what_it_cannot_take() {
+ let mut s = session();
+ let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0);
+ let flat = s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap();
+ let lib = s.execute("brush.list", &json!({})).unwrap();
+ let steps = undo_steps(&s);
+ for (p, msg) in [
+ (json!({"name": "3 pt. Round", "ids": [r.0]}), "`3 pt. Round` has no art (only scatter, art and pattern brushes do)"),
+ (json!({"name": "Bristle Round", "ids": [r.0]}), "`Bristle Round` has no art (only scatter, art and pattern brushes do)"),
+ (json!({"name": "Nope", "ids": [r.0]}), "no brush named `Nope`"),
+ (json!({"name": "Arrow", "ids": [flat]}), "the selected art has no width"),
+ (json!({"name": "Chain", "ids": [flat]}), "the selected art has no width"),
+ (json!({"name": "Arrow", "ids": [r.0], "applyToStrokes": "no"}), "`applyToStrokes` must be true or false"),
+ ] {
+ let e = s.execute("brush.replaceArt", &p).unwrap_err();
+ assert!(e.to_string().contains(msg), "{p}: {e}");
+ }
+ s.select(|_, sel| sel.clear()).unwrap();
+ let e = s.execute("brush.replaceArt", &json!({"name": "Arrow"})).unwrap_err();
+ assert!(e.to_string().contains("nothing selected"), "{e}");
+ assert_eq!(undo_steps(&s), steps);
+ assert_eq!(s.execute("brush.list", &json!({})).unwrap(), lib);
+}
+
+/// An instance of a symbol whose art has no width is art with no width for `brush.new` and
+/// `brush.replaceArt`, although an instance's box without its document has width.
+#[test]
+fn a_symbol_whose_art_has_no_width_makes_no_brush_art() {
+ let mut s = session();
+ let flat = NodeId(s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap());
+ let art = node(&s, flat);
+ s.execute("edit.clear", &json!({"ids": [flat.0]})).unwrap();
+ let st = s.doc_mut().unwrap();
+ let d = std::sync::Arc::make_mut(&mut st.doc);
+ d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Flat".into(), art: std::sync::Arc::new(art) });
+ let layer = d.layers[0].id;
+ let inst = d.alloc_id();
+ d.insert(Some(layer), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Flat".into(), xf: vectorcraft_geom::Affine::IDENTITY }))
+ .unwrap();
+ assert!(node(&s, inst).geometric_bounds().is_some_and(|b| b.width() > 1.0), "the box an instance has without its document");
+ for (cmd, p) in [("brush.new", json!({"type": "art", "ids": [inst.0]})), ("brush.replaceArt", json!({"name": "Arrow", "ids": [inst.0]}))] {
+ let e = s.execute(cmd, &p).unwrap_err();
+ assert!(e.to_string().contains("the selected art has no width"), "{cmd}: {e}");
+ }
+}
+
+/// `brush.replaceArt` checks only the art it is given: a tile the pattern brush already has that
+/// has no width does not stop another tile from being replaced.
+#[test]
+fn replace_art_checks_only_the_tiles_it_is_given() {
+ let mut s = session();
+ let flat = NodeId(s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap());
+ let end = art_json(&mut s, flat);
+ s.execute("brush.options", &json!({"name": "Chain", "params": {"end": end}})).unwrap();
+ let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0);
+ s.execute("brush.replaceArt", &json!({"name": "Chain", "ids": [r.0]})).unwrap();
+ let side: Node = serde_json::from_value(s.execute("brush.get", &json!({"name": "Chain"})).unwrap()["side"].clone()).unwrap();
+ assert!(close(side.geometric_bounds().unwrap(), bounds(&s, r), 1e-6));
+}
+
+/// Tile `key` (`side`, `start`…) of pattern brush `name`.
+fn tile_art(s: &mut Session, name: &str, key: &str) -> Node {
+ serde_json::from_value(s.execute("brush.get", &json!({"name": name})).unwrap()[key].clone()).unwrap()
+}
+
+/// A pattern brush tile placed with `brush.placeArt` and given back with `brush.replaceArt` is the
+/// art it was, not that art inside the tile's group, however many times it goes out and back.
+#[test]
+fn pattern_tiles_go_out_and_back_without_gaining_a_group() {
+ let mut s = session();
+ let name = framed_pattern_brush(&mut s);
+ let size = |n: &Node| {
+ let b = n.geometric_bounds().unwrap();
+ (b.width(), b.height())
+ };
+ let first = tile_art(&mut s, &name, "side");
+ assert!(matches!(first.kind, NodeKind::Path { .. }), "{:?}", first.kind);
+ for round in 1..=2 {
+ let id = s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap();
+ s.execute("brush.replaceArt", &json!({"name": name, "ids": [id]})).unwrap();
+ let side = tile_art(&mut s, &name, "side");
+ assert!(matches!(side.kind, NodeKind::Path { .. }), "round {round}: {:?}", side.kind);
+ let (a, b) = (size(&first), size(&side));
+ assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "round {round}: {a:?} → {b:?}");
+ }
+}
+
+/// A tile group whose art was deleted, leaving only the rectangle that marks its frame, is art with
+/// no width: the brush keeps its tile.
+#[test]
+fn a_tile_group_holding_only_its_frame_is_refused() {
+ let mut s = session();
+ let name = framed_pattern_brush(&mut s);
+ let before = s.execute("brush.get", &json!({"name": name})).unwrap();
+ let id = NodeId(s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap());
+ let side = node(&s, id).children().unwrap()[0].clone();
+ assert_eq!(side.name.as_deref(), Some("Side Tile"));
+ let art = side.children().unwrap()[1].id;
+ s.execute("edit.clear", &json!({"ids": [art.0]})).unwrap();
+ let steps = undo_steps(&s);
+ let e = s.execute("brush.replaceArt", &json!({"name": name, "ids": [side.id.0]})).unwrap_err();
+ assert!(e.to_string().contains("the selected art has no width"), "{e}");
+ assert_eq!(undo_steps(&s), steps);
+ assert_eq!(s.execute("brush.get", &json!({"name": name})).unwrap(), before);
+}
+
+/// Two groups named for the same tile: the frontmost one is the tile's art.
+#[test]
+fn the_frontmost_group_named_for_a_tile_is_that_tile() {
+ let mut s = session();
+ let name = framed_pattern_brush(&mut s);
+ let back = rect(&mut s, 10.0, 10.0, 20.0, 10.0);
+ let front = rect(&mut s, 10.0, 40.0, 30.0, 10.0);
+ let (back, front) = (node(&s, back), node(&s, front));
+ s.execute("edit.clear", &json!({"ids": [back.id.0, front.id.0]})).unwrap();
+ let st = s.doc_mut().unwrap();
+ let d = std::sync::Arc::make_mut(&mut st.doc);
+ let tile = |d: &mut Document, n: Node| {
+ let mut g = Node::group(d.alloc_id(), vec![std::sync::Arc::new(n)]);
+ g.name = Some("Start Tile".into());
+ std::sync::Arc::new(g)
+ };
+ let tiles = vec![tile(d, back), tile(d, front)];
+ let both = Node::group(d.alloc_id(), tiles);
+ let both_id = both.id;
+ let layer = d.layers[0].id;
+ d.insert(Some(layer), usize::MAX, both).unwrap();
+ s.execute("brush.replaceArt", &json!({"name": name, "ids": [both_id.0]})).unwrap();
+ let start = tile_art(&mut s, &name, "start");
+ assert!((start.geometric_bounds().unwrap().width() - 30.0).abs() < 1e-6, "{:?}", start.geometric_bounds());
+}
+
+/// Type is art for `brush.replaceArt` as it is for `brush.new`: taken as it is.
+#[test]
+fn replace_art_takes_type_as_brush_new_does() {
+ let mut s = session();
+ let t = s.execute("text.create", &json!({"x": 20, "y": 60, "text": "Hi", "size": 24})).unwrap()["id"].as_u64().unwrap();
+ let want = art_json(&mut s, NodeId(t));
+ s.execute("brush.replaceArt", &json!({"name": "Arrow", "ids": [t]})).unwrap();
+ assert_eq!(s.execute("brush.get", &json!({"name": "Arrow"})).unwrap()["art"], want);
+ let made = s.execute("brush.new", &json!({"type": "art", "ids": [t]})).unwrap()["name"].as_str().unwrap().to_string();
+ assert_eq!(s.execute("brush.get", &json!({"name": made})).unwrap()["art"], want);
+}
+
#[test]
fn brush_options_rename_duplicate_and_delete_update_strokes() {
let mut s = session();
diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs
index ae1a72fa08..20cad1dc01 100644
--- a/crates/engine/src/tests/dynamic_text.rs
+++ b/crates/engine/src/tests/dynamic_text.rs
@@ -234,6 +234,26 @@ fn svg_export_writes_the_drawn_size() {
assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}");
}
+#[test]
+fn release_type_on_a_path_keeps_the_drawn_size() {
+ let mut s = session();
+ let id = on_line(&mut s, "Release");
+ s.execute("type.dynamicText", &json!({"on": true})).unwrap();
+ let size = 12.0 * fit(&s, id);
+ assert!(size > 24.0, "the text grows on the long line: {size}");
+ let r = s.execute("addon.type.releasePath", &json!({"ids": [id.0], "mode": "text"})).unwrap();
+ let released = NodeId(r["texts"][0].as_u64().unwrap());
+ let t = text(&s, released);
+ assert!(!t.dynamic_text, "point type does not keep Dynamic Text");
+ assert!((t.runs[0].style.size - size).abs() < 1e-9, "{} vs the drawn {size}", t.runs[0].style.size);
+ assert_eq!(fit(&s, released), 1.0);
+ // Type on a path without Dynamic Text keeps its stored size.
+ let plain = on_line(&mut s, "Plain");
+ let r = s.execute("addon.type.releasePath", &json!({"ids": [plain.0], "mode": "text"})).unwrap();
+ let t = text(&s, NodeId(r["texts"][0].as_u64().unwrap()));
+ assert!(!t.dynamic_text && t.runs[0].style.size == 12.0);
+}
+
/// 12 pt point type at (100, 100), selected, with its layout bounds.
fn point(s: &mut Session, content: &str) -> (NodeId, vectorcraft_geom::Rect) {
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": content, "size": 12})).unwrap();
diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs
index 1e13c468c3..24a1635c30 100644
--- a/crates/engine/src/tests/ids_without_selection.rs
+++ b/crates/engine/src/tests/ids_without_selection.rs
@@ -94,10 +94,49 @@ fn run_fresh(
params: &serde_json::Value,
) -> (std::result::Result, Option>) {
let (mut s, ..) = doc_with_type();
- let r = s.execute(cmd, params).map_err(|e| e.to_string());
+ let r = s.execute(cmd, params).map(|v| without_plugin_effects(cmd, v)).map_err(|e| e.to_string());
(r, s.doc().ok().map(|d| d.doc.clone()))
}
+/// `cmd`'s result with the plug-in effects (`plugin.`) taken out of `effect.list`'s catalog.
+/// The plug-in tests install and remove plug-ins in the process-wide registry while these
+/// comparisons run, so two runs of `effect.list` can list different plug-ins.
+fn without_plugin_effects(cmd: &str, mut r: serde_json::Value) -> serde_json::Value {
+ if cmd == "effect.list"
+ && let Some(catalog) = r.get_mut("catalog").and_then(serde_json::Value::as_array_mut)
+ {
+ catalog.retain(|e| !e["id"].as_str().is_some_and(|id| id.starts_with(vectorcraft_plugins::PREFIX)));
+ }
+ r
+}
+
+/// Whether two results of `cmd` are the same, apart from the plug-in effects in `effect.list`'s
+/// catalog ([`without_plugin_effects`]).
+fn same_without_plugin_effects(cmd: &str, x: &serde_json::Value, y: &serde_json::Value) -> bool {
+ if cmd == "effect.list" { without_plugin_effects(cmd, x.clone()) == without_plugin_effects(cmd, y.clone()) } else { x == y }
+}
+
+/// Two `effect.list` results that differ only in a plug-in effect in the catalog compare the same;
+/// a difference in a built-in effect, in `applied` or in `activeItem`, or in another command's
+/// result, does not.
+#[test]
+fn plugin_effects_are_left_out_of_the_effect_list_comparison() {
+ let list = |catalog: &[&str], applied: serde_json::Value, active: serde_json::Value| {
+ let catalog: Vec<_> = catalog.iter().map(|id| json!({"id": id, "label": id})).collect();
+ json!({"catalog": catalog, "applied": applied, "activeItem": active})
+ };
+ let base = list(&["stylize.dropShadow"], json!([]), json!(null));
+ let with_plugin = list(&["stylize.dropShadow", "plugin.test.glow"], json!([]), json!(null));
+ assert!(same_without_plugin_effects("effect.list", &base, &with_plugin));
+ assert!(same_without_plugin_effects("effect.list", &with_plugin, &base));
+ assert_eq!(without_plugin_effects("effect.list", with_plugin.clone()), base);
+ assert!(!same_without_plugin_effects("effect.list", &base, &list(&[], json!([]), json!(null))), "a built-in effect");
+ assert!(!same_without_plugin_effects("effect.list", &base, &list(&["stylize.dropShadow"], json!([{"id": 3}]), json!(null))), "applied");
+ assert!(!same_without_plugin_effects("effect.list", &base, &list(&["stylize.dropShadow"], json!([]), json!(0))), "activeItem");
+ assert!(!same_without_plugin_effects("object.transform", &base, &with_plugin), "another command");
+ assert_eq!(without_plugin_effects("object.transform", with_plugin.clone()), with_plugin);
+}
+
/// Every command whose params description names both `id` and `ids` (`{ids?|id?}`, `{id?, ids?:
/// … (instead of id)}`), with nothing selected: `id` does what `ids` of that one object does (the
/// same result or the same error, and the same document), for objects of the document and for an
@@ -265,7 +304,7 @@ fn ids_and_selection(cmd: &str, picks: &[usize]) -> std::result::Result x == y,
+ (Ok(x), Ok(y)) => same_without_plugin_effects(cmd, x, y),
(Err(x), Err(y)) => x.to_string() == y.to_string(),
_ => false,
};
diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs
index 30a8776b35..5ce4a3757e 100644
--- a/crates/engine/src/tests/library_roots.rs
+++ b/crates/engine/src/tests/library_roots.rs
@@ -214,26 +214,72 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() {
let mine = format!("loaded/{}", text(&inside));
let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine})));
assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}");
- assert_eq!(copied["path"], Value::Null, "{copied}");
+ assert_eq!(copied.get("path"), None, "{copied}");
let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"})));
assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}");
for reply in [&added, &copied, &saved] {
assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}");
}
- // A library loaded from data under the file name keeps its id; each concealed one takes the next
- // free one, so no two rows share an id.
+ // A library loaded from data under the file name keeps its id; the concealed one shown under that
+ // id takes the next free one, the other keeps its id, and no two rows share an id.
let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string();
run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"}));
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
let ids: Vec = loaded(&list).into_iter().map(|(id, ..)| id).collect();
- assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}");
+ assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches")], "{list}");
// Copied into the user library folder, it is still found by that id, which no other library takes.
confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"})));
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
let ids: Vec = loaded(&list).into_iter().map(|(id, ..)| id).collect();
- assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}");
+ assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)")], "{list}");
// The person sees the folder.
let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"}));
assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches"))));
let _ = std::fs::remove_dir_all(base);
}
+
+/// Outside Windows a `\` is part of a file name: a concealed library saved at `/weird\name.gpl`
+/// is shown under its whole file name. Another platform's absolute path is still named by the part
+/// after its last `\`.
+#[cfg(not(windows))]
+#[test]
+fn a_concealed_library_keeps_a_backslash_in_its_file_name() {
+ let base = folder("backslash-name");
+ let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
+ let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a")));
+ let foreign = r"loaded/C:\Users\someone\other.gpl".to_string();
+ let s = Session::new();
+ let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()]));
+ let _ = std::fs::remove_dir_all(base);
+ hidden.sort();
+ let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())];
+ want.sort();
+ assert_eq!(hidden, want);
+}
+
+/// A concealed library keeps the id a client was shown when another library with the same file name,
+/// whose full id sorts before it, is loaded later: the id still finds the first library.
+#[test]
+fn a_concealed_library_keeps_its_id_after_another_with_its_file_name_loads() {
+ let base = folder("keeps-id");
+ let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap();
+ let (first, later) = (base.join("b").join("Kit.vcswatches"), base.join("a").join("Kit.vcswatches"));
+ let mut s = Session::new();
+ run(&mut s, "file.new", json!({"width": 100, "height": 100}));
+ (SWATCHES.add)(&mut s, "First");
+ run(&mut s, "swatch.library.save", json!({"path": text(&first), "name": "Kit"}));
+ (SWATCHES.add)(&mut s, "Second");
+ run(&mut s, "swatch.library.save", json!({"path": text(&later), "name": "Kit"}));
+ let shown = "loaded/Kit.vcswatches";
+ let count = run(&mut s, "swatch.library.load", json!({"path": text(&first)}))["count"].clone();
+ let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
+ assert_eq!(loaded(&list), [(json!(shown), Value::Null, count)], "{list}");
+ run(&mut s, "swatch.library.load", json!({"path": text(&later)}));
+ let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
+ let ids: Vec = loaded(&list).into_iter().map(|(id, ..)| id).collect();
+ assert_eq!(ids, [json!(shown), json!("loaded/Kit.vcswatches (2)")], "{list}");
+ let theirs = run(&mut s, "swatch.library.get", json!({"library": format!("loaded/{}", text(&first))}));
+ let got = confine(roots.as_ref(), || run(&mut s, "swatch.library.get", json!({"library": shown})));
+ assert_eq!((&got["id"], &got["swatches"]), (&json!(shown), &theirs["swatches"]), "the first library: {got}");
+ let _ = std::fs::remove_dir_all(base);
+}
diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs
index 28cb0a1590..44a0ddde1b 100644
--- a/crates/engine/src/tests/menucmds.rs
+++ b/crates/engine/src/tests/menucmds.rs
@@ -4489,6 +4489,41 @@ fn cmd_ruler_drags_make_guides_of_the_active_artboard_when_the_preference_is_on(
assert_eq!(owners(&mut s)[4..], [Value::Null, json!(1)]);
}
+/// The rulers' origin dragged out of their corner snaps as a drawn point does: onto a shape's
+/// corner and edge, the artboard's centre, and with Snap to Point along a ruler guide; with
+/// nothing on it stays at the pointer. The drag changes nothing, and its guides go when it ends.
+#[test]
+fn the_ruler_origin_dragged_from_the_corner_snaps_as_a_drawn_point() {
+ use vectorcraft_geom::Point;
+ let mut s = session();
+ rect(&mut s, 100.0, 100.0, 100.0, 100.0);
+ s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap();
+ let (n, doc) = (undo_len(&s), s.doc().unwrap().doc.clone());
+ let v = ViewInfo::default();
+ let at = |s: &mut Session, x, y| s.ruler_origin_drag(Some(Point::new(x, y)), v).unwrap();
+ assert_eq!(at(&mut s, 101.5, 98.5), Point::new(100.0, 100.0), "the corner");
+ assert!(!s.overlays(v).is_empty(), "the guide showing it");
+ assert_eq!(at(&mut s, 130.0, 202.0), Point::new(130.0, 200.0), "the bottom edge");
+ assert_eq!(at(&mut s, 398.0, 301.5), Point::new(400.0, 300.0), "the artboard's centre");
+ assert_eq!(at(&mut s, 302.0, 50.0).x, 300.0, "along the ruler guide");
+ assert_eq!(s.ruler_origin_drag(None, v), None, "off the canvas");
+ assert!(s.overlays(v).is_empty());
+ assert_eq!(at(&mut s, 101.5, 98.5), Point::new(100.0, 100.0));
+ s.end_ruler_origin_drag();
+ assert!(s.overlays(v).is_empty());
+ assert_eq!((undo_len(&s), &*s.doc().unwrap().doc), (n, &*doc), "nothing changed");
+ // Nothing on: it stays at the pointer.
+ let off = ViewInfo { smart_guides: false, snap_to_point: false, ..v };
+ assert_eq!(s.ruler_origin_drag(Some(Point::new(101.5, 98.5)), off), Some(Point::new(101.5, 98.5)));
+ s.end_ruler_origin_drag();
+ // Snap to Grid: onto a gridline crossing.
+ let grid = ViewInfo { snap_to_grid: true, ..v };
+ let p = s.ruler_origin_drag(Some(Point::new(101.5, 98.5)), grid).unwrap();
+ s.end_ruler_origin_drag();
+ assert_eq!(p, Point::new(p.x.round(), p.y.round()), "{p:?}");
+ assert_ne!(p, Point::new(101.5, 98.5));
+}
+
/// Guides saved before guides had layers are put on a layer when the document opens.
#[test]
fn guides_from_older_files_are_put_on_a_layer() {
diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs
index 01550b5ad1..c1da365740 100644
--- a/crates/engine/src/tests/package.rs
+++ b/crates/engine/src/tests/package.rs
@@ -280,6 +280,103 @@ fn a_missing_link_is_reported_not_copied() {
assert!(report.contains("blue.png: not found"), "{report}");
}
+/// Packaged for a client whose read root holds neither the document's file nor its links: the report,
+/// its Document Info part included, names them by their file names, and `document.info` gives no
+/// link. The person's report names them whole.
+#[test]
+fn a_confined_package_names_files_outside_the_read_root_by_name() {
+ let dir = Folder::new("package-roots");
+ let mut s = poster(&dir);
+ std::fs::create_dir_all(dir.file("in")).unwrap();
+ std::fs::remove_file(dir.file("art/blue.png")).unwrap();
+ open(&mut s, &dir.file("poster.vectorcraft"));
+ let report = |r: &Value| {
+ let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
+ String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap()
+ };
+ let person = report(&run(&mut s, "file.package", json!({})));
+ assert!(person.contains(&format!("From: {}\n", dir.file("poster.vectorcraft"))), "{person}");
+ assert!(person.contains(&format!("{} → Links/red.png", dir.file("art/red.png"))), "{person}");
+ assert!(person.contains(&format!("blue.png: not found ({})", dir.file("art/blue.png"))), "{person}");
+
+ let in_root = dir.0.join("in");
+ let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap();
+ let client = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
+ let text = report(&client);
+ assert!(text.contains("From: poster.vectorcraft\n"), "{text}");
+ assert!(text.contains("\nred.png → Links/red.png\n") && text.contains("\nblue.png: not found (blue.png), not copied\n"), "{text}");
+ let base = dir.0.to_string_lossy().into_owned();
+ assert!(!text.contains(&base), "{text}");
+ for key in ["missingLinks", "warnings", "files"] {
+ assert!(!client[key].to_string().contains(&base), "{key}: {client}");
+ }
+ // Document Info, whose report the package holds, gives no link either.
+ for p in [json!({}), json!({"format": "text"})] {
+ let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", p.clone()));
+ assert!(!info.to_string().contains(&base), "{p}: {info}");
+ }
+ let info = run(&mut s, "document.info", json!({"format": "text"}));
+ assert!(info.to_string().contains(&dir.file("art/red.png")), "the person's: {info}");
+}
+
+/// A missing link stored as a path relative to the document names a file inside a confined
+/// request's read root: the package report and Document Info give its stored path.
+#[test]
+fn a_relative_link_inside_the_read_root_keeps_its_path() {
+ let dir = Folder::new("package-roots-relative");
+ write(&dir.file("art/near.png"), &png(8, 8, RED));
+ let mut s = session();
+ let id = place(&mut s, &dir.file("art/near.png"));
+ let mut doc = (*s.doc().unwrap().doc).clone();
+ let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") };
+ im.link = Some(vectorcraft_doc::LinkInfo::new("art/near.png"));
+ write(&dir.file("near.vectorcraft"), &vectorcraft_format::save(&doc, false));
+ // Missing, the link keeps its stored path when the document opens (the package copies the
+ // image the document holds).
+ std::fs::remove_file(dir.file("art/near.png")).unwrap();
+ open(&mut s, &dir.file("near.vectorcraft"));
+ let roots = crate::file_access::AutomationRoots::new(&[dir.0.as_path()], &[]).unwrap();
+ let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
+ let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
+ let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap();
+ assert!(text.contains("\nart/near.png → Links/near.png\n"), "{text}");
+ let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({})));
+ let links: Vec<&Value> = info["images"].as_object().unwrap().values().map(|i| &i["link"]).collect();
+ assert_eq!(links, [&json!("art/near.png")], "{info}");
+ let text = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({"format": "text"})));
+ assert!(text.to_string().contains("art/near.png"), "{text}");
+}
+
+/// A link whose stored path names a place outside a confined request's read root, with its file
+/// found by name beside the document inside the root: the package report and reply give the file
+/// name, not the stored path.
+#[test]
+fn a_stored_path_outside_the_read_root_is_left_out_when_its_file_is_found_inside() {
+ let dir = Folder::new("package-roots-stale");
+ write(&dir.file("in/near.png"), &png(8, 8, RED));
+ let mut s = session();
+ let id = place(&mut s, &dir.file("in/near.png"));
+ let mut doc = (*s.doc().unwrap().doc).clone();
+ let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") };
+ let stale = dir.file("out/near.png");
+ im.link = Some(vectorcraft_doc::LinkInfo::new(&stale));
+ write(&dir.file("in/near.vectorcraft"), &vectorcraft_format::save(&doc, false));
+ // Missing when the document opens, the link keeps its stored path; the file then appears beside it.
+ std::fs::remove_file(dir.file("in/near.png")).unwrap();
+ open(&mut s, &dir.file("in/near.vectorcraft"));
+ write(&dir.file("in/near.png"), &png(8, 8, RED));
+ let in_root = dir.0.join("in");
+ let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap();
+ let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
+ let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
+ let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap();
+ assert!(text.contains("\nnear.png → Links/near.png\n"), "{text}");
+ assert!(!text.contains(&stale), "{text}");
+ for key in ["missingLinks", "warnings", "files"] {
+ assert!(!r[key].to_string().contains(&stale), "{key}: {r}");
+ }
+}
+
/// A file missing from a document that moved keeps the place its relative path names from the
/// document's folder: the packaged document writes its relative path from there.
#[test]
diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs
index 4d225e4bdc..a1e615a08d 100644
--- a/crates/engine/src/tests/plugins.rs
+++ b/crates/engine/src/tests/plugins.rs
@@ -1153,6 +1153,98 @@ fn deferred_stages_in_symbol_art_are_listed_and_refreshed() {
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([]));
}
+/// `effect.refreshDeferred` takes the id of an object in the art of a symbol, pattern, brush or
+/// opacity mask, as `effect.deferredStatus` lists it: one with a deferred stage is refreshed, and
+/// one without (or whose stage is gone) adds nothing to the counts. The id of art that is no
+/// longer in the document is an error.
+#[test]
+fn refresh_takes_objects_in_definition_art() {
+ let mut s = session();
+ const FX: &str = "plugin.test.engine.deferred.nested";
+ let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
+ install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.nested", "effect", "{}"), &out));
+ // A symbol whose art holds an object with the effect and one without.
+ let staged = rect(&mut s, 10.0, "#ff0000");
+ let plain = rect(&mut s, 30.0, "#ff0000");
+ run(&mut s, "effect.apply", json!({"ids": [staged.0], "effect": FX}));
+ run(&mut s, "select.set", json!({"ids": [staged.0, plain.0]}));
+ run(&mut s, "symbol.new", json!({"name": "Nested"}));
+ // A pattern, an art brush (its source deleted) and an opacity mask, none with a stage.
+ let tile = rect(&mut s, 50.0, "#00ff00");
+ run(&mut s, "select.set", json!({"ids": [tile.0]}));
+ run(&mut s, "object.pattern.make", json!({"name": "Tile", "width": 20, "height": 20}));
+ run(&mut s, "object.pattern.done", json!({}));
+ let bar = rect(&mut s, 70.0, "#0000ff");
+ run(&mut s, "select.set", json!({"ids": [bar.0]}));
+ run(&mut s, "brush.new", json!({"type": "art", "name": "Bar"}));
+ run(&mut s, "edit.clear", json!({"ids": [bar.0]}));
+ let masked = rect(&mut s, 90.0, "#ff0000");
+ let mask = rect(&mut s, 90.0, "#ffffff");
+ run(&mut s, "select.set", json!({"ids": [masked.0, mask.0]}));
+ run(&mut s, "transparency.makeOpacityMask", json!({}));
+ // A layer object with a stage that stays deferred, and one without.
+ let kept = rect(&mut s, 130.0, "#ff0000");
+ run(&mut s, "effect.apply", json!({"ids": [kept.0], "effect": FX}));
+ let bare = rect(&mut s, 150.0, "#ff0000");
+
+ // The ids of the objects of `art` that are not in the layers.
+ let nested = |s: &Session, art: &vectorcraft_doc::Node| {
+ let d = &s.doc().unwrap().doc;
+ let mut ids = Vec::new();
+ vectorcraft_doc::swatches::walk_with_masks(art, &mut |n| {
+ if d.node(n.id).is_none() {
+ ids.push(n.id.0);
+ }
+ });
+ ids
+ };
+ let d = s.doc().unwrap().doc.clone();
+ let in_symbol = nested(&s, &d.symbols[0].art);
+ let in_pattern = d.patterns.iter().flat_map(|p| p.art.iter().flat_map(|a| nested(&s, a))).collect::>();
+ let brushes = vectorcraft_brush::library(&d);
+ let in_brush = brushes.iter().flat_map(|b| b.art().into_iter().flat_map(|a| nested(&s, a)).collect::>()).collect::>();
+ let in_mask = d.node(masked).and_then(|n| n.extras().mask.as_ref()).map(|m| nested(&s, &m.art)).unwrap_or_default();
+ for (what, ids) in [("symbol", &in_symbol), ("pattern", &in_pattern), ("brush", &in_brush), ("opacity mask", &in_mask)] {
+ assert!(!ids.is_empty(), "the {what} art has an object outside the layers");
+ }
+
+ let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
+ let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
+ s.doc_mut().unwrap().plugin_effects = fx.clone();
+ fx.view(|| drawn(&s));
+ fx.first_view_done();
+ let listed = |s: &mut Session| {
+ run(s, "effect.deferredStatus", json!({}))["effects"].as_array().unwrap().iter().map(|e| e["objectId"].as_u64().unwrap()).collect::>()
+ };
+ let deferred = listed(&mut s);
+ let [nested_staged, last] = deferred[..] else { panic!("two stages: {deferred:?}") };
+ assert!(in_symbol.contains(&nested_staged) && last == kept.0, "{deferred:?} {in_symbol:?}");
+ let Some(&nested_plain) = in_symbol.iter().find(|i| **i != nested_staged) else { panic!("{in_symbol:?}") };
+
+ // Objects without a stage, each on its own and together: nothing to refresh.
+ let none = json!({"refreshed": 0, "stillDeferred": 0, "failed": 0});
+ let mut plain_ids = vec![nested_plain, in_pattern[0], in_brush[0], in_mask[0], bare.0];
+ for id in &plain_ids {
+ assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": id})), none, "object {id}");
+ }
+ assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), none);
+ assert_eq!(listed(&mut s), [nested_staged, kept.0]);
+
+ // The staged object of the symbol's art, by its id, with the others.
+ step.store(0, std::sync::atomic::Ordering::Relaxed);
+ plain_ids.push(nested_staged);
+ assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0}));
+ assert_eq!(listed(&mut s), [kept.0]);
+ // Its stage is gone; the object is still there.
+ assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": nested_staged})), none);
+
+ // Art no longer in the document, and an id of no object.
+ std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns.clear();
+ assert!(s.execute("effect.refreshDeferred", &json!({"id": in_pattern[0]})).is_err(), "the pattern is gone");
+ assert!(s.execute("effect.refreshDeferred", &json!({"ids": [nested_plain, 999_999]})).is_err(), "not an object of the document");
+ assert_eq!(listed(&mut s), [kept.0]);
+}
+
/// `CRAFT_PLUGIN_PAINTS_WASM=` saves a test live effect to try in the app
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs
index f0b6a0708c..8248f12a48 100644
--- a/crates/engine/src/tests/recovery.rs
+++ b/crates/engine/src/tests/recovery.rs
@@ -483,6 +483,105 @@ fn the_folder_store_reads_no_linked_entries() {
let _ = std::fs::remove_dir_all(&base);
}
+/// A file where a folder of the store's path should be: the error names that file, not the area
+/// folder or the copy below it, for a write, a lock, a read, a listing, a removal and a recovery
+/// save.
+#[cfg(unix)]
+#[test]
+fn folder_errors_name_a_file_in_the_way() {
+ let base = std::env::temp_dir().join(format!("vc-recovery-file-in-way-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&base);
+ std::fs::create_dir_all(&base).unwrap();
+ let file = base.join("file");
+ std::fs::write(&file, b"").unwrap();
+ let folder = file.join("recovery");
+ let store = FolderStore::new(&folder);
+ let mut s = Session::new();
+ s.prefs.recovery_folder = folder.to_string_lossy().to_string();
+ s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
+ s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
+ let errors = [
+ ("write", store.write("1-1/Untitled-1-1.vectorcraft", b"copy").err()),
+ ("lock", store.lock("1-2").err()),
+ ("read", store.read("1-1/Untitled-1-1.vectorcraft").err()),
+ ("list", store.list().err()),
+ ("remove", store.remove("1-1/Untitled-1-1.vectorcraft").err()),
+ ("save", s.execute("file.recovery.save", &json!({})).err().map(|e| e.to_string())),
+ ];
+ let _ = std::fs::remove_dir_all(&base);
+ let named = format!("{} is not a folder: ", file.display());
+ let wrong: Vec<_> = errors.iter().filter(|(_, e)| !e.as_deref().is_some_and(|e| e.contains(&named))).collect();
+ assert!(wrong.is_empty(), "{wrong:?}");
+}
+
+/// A removal that finds the copy missing because a file is on the way to it (how Windows reports a
+/// file in the way) names that file. A copy that is just not there still counts as removed.
+#[test]
+fn a_removal_reported_missing_names_a_file_in_the_way() {
+ let base = std::env::temp_dir().join(format!("vc-recovery-remove-in-way-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&base);
+ std::fs::create_dir_all(&base).unwrap();
+ let file = base.join("file");
+ std::fs::write(&file, b"").unwrap();
+ let missing = || std::io::Error::from(std::io::ErrorKind::NotFound);
+ let behind = FolderStore::removed(&file.join("recovery").join("1-1").join("Untitled-1-1.vectorcraft"), Err(missing()));
+ let gone = FolderStore::removed(&base.join("recovery").join("1-1").join("Untitled-1-1.vectorcraft"), Err(missing()));
+ let _ = std::fs::remove_dir_all(&base);
+ let named = format!("{} is not a folder: ", file.display());
+ assert!(behind.as_ref().is_err_and(|e| e.starts_with(&named)), "{behind:?}");
+ assert_eq!(gone, Ok(()));
+}
+
+/// A folder of the store's path that is missing and can't be made: a write or a lock names it as
+/// the folder it couldn't make, and a read names it as missing. An area folder that can't be made
+/// in a store folder that is there is named the same way.
+#[cfg(unix)]
+#[test]
+fn folder_errors_name_a_missing_folder_that_cannot_be_made() {
+ use std::os::unix::fs::PermissionsExt as _;
+ let base = std::env::temp_dir().join(format!("vc-recovery-missing-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&base);
+ let closed = base.join("closed");
+ std::fs::create_dir_all(&closed).unwrap();
+ std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o500)).unwrap();
+ let gone = closed.join("gone");
+ let store = FolderStore::new(gone.join("recovery"));
+ let unmade = format!("couldn't make {}: ", gone.display());
+ let area = FolderStore::new(&closed).write("1-1/Untitled-1-1.vectorcraft", b"copy").err();
+ let errors = [
+ ("write", store.write("1-1/Untitled-1-1.vectorcraft", b"copy").err(), unmade.clone()),
+ ("lock", store.lock("1-2").err(), unmade),
+ ("read", store.read("1-1/Untitled-1-1.vectorcraft").err(), format!("{} is missing", gone.display())),
+ ("area", area, format!("couldn't make {}: ", closed.join("1-1").display())),
+ ];
+ std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o700)).unwrap();
+ let _ = std::fs::remove_dir_all(&base);
+ // An account that can write anywhere (root) makes the folders, so the test has nothing to check.
+ if errors[0].1.is_none() {
+ return;
+ }
+ let wrong: Vec<_> = errors.iter().filter(|(_, e, named)| !e.as_deref().is_some_and(|e| e.starts_with(named.as_str()))).collect();
+ assert!(wrong.is_empty(), "{wrong:?}");
+}
+
+/// When every folder on the way is there, an error names the folder it happened on.
+#[cfg(unix)]
+#[test]
+fn folder_errors_name_the_folder_when_every_folder_is_there() {
+ use std::os::unix::fs::PermissionsExt as _;
+ let base = std::env::temp_dir().join(format!("vc-recovery-unreadable-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&base);
+ let unreadable = base.join("unreadable");
+ std::fs::create_dir_all(&unreadable).unwrap();
+ std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o300)).unwrap();
+ let listed = FolderStore::new(&unreadable).list();
+ std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o700)).unwrap();
+ let _ = std::fs::remove_dir_all(&base);
+ // An account that can read any folder (root) lists it, so the test has nothing to check.
+ let Err(err) = listed else { return };
+ assert!(err.starts_with(&format!("{}: ", unreadable.display())), "{err}");
+}
+
/// A store in memory that notes each removal of an area (its lock file and folder, then its empty
/// folder) and whether the area was still held then.
#[derive(Default)]
@@ -958,6 +1057,142 @@ fn a_copy_left_behind_is_not_offered_by_its_own_app() {
let _ = std::fs::remove_dir_all(&dir);
}
+/// A copy left in an area the session had to leave is not another app's to offer, restore or
+/// discard while the session runs, even after the app that locked that area let it go: an entry in
+/// the session's new area says it keeps it. Once the copy is written again in the new area, the
+/// entry goes; after a crash before that, the copy is offered.
+#[cfg(unix)]
+#[test]
+fn a_copy_left_behind_is_not_offered_by_another_app_while_its_app_runs() {
+ use cmd::recovery::Lock;
+ let dir = std::env::temp_dir().join(format!("vc-recovery-leftother-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let left_behind = |area: &str| dir.join(area).join("Untitled-1-1.vectorcraft");
+ let leave = |a: &mut Session, area: &str| {
+ std::fs::remove_file(dir.join(area).join(".lock")).unwrap();
+ let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(area) else { panic!("the other app locks the area") };
+ cmd::recovery::heartbeat(a);
+ drop(other);
+ a.recovery.own_area().unwrap().to_string()
+ };
+ let (mut a, area) = saved_in(&dir);
+ let mine = leave(&mut a, &area);
+ assert_ne!(mine, area);
+ assert!(dir.join(&mine).join(format!("keeps-{area}")).is_file());
+ // Removed meanwhile: the next heartbeat writes it again.
+ std::fs::remove_file(dir.join(&mine).join(format!("keeps-{area}"))).unwrap();
+ cmd::recovery::heartbeat(&mut a);
+ assert!(dir.join(&mine).join(format!("keeps-{area}")).is_file());
+ let mut b = other_app(&dir);
+ assert!(offered(&mut b).is_empty());
+ assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0);
+ assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty());
+ assert!(b.execute("file.recovery.discard", &json!({"file": format!("{area}/Untitled-1-1")})).is_err());
+ assert!(left_behind(&area).is_file());
+ // Written again in the new area: the copy left behind and the entry go.
+ a.execute("file.recovery.save", &json!({})).unwrap();
+ assert!(!left_behind(&area).exists());
+ assert!(!dir.join(&mine).join(format!("keeps-{area}")).exists());
+ assert!(offered(&mut b).is_empty());
+ drop(a);
+ let _ = std::fs::remove_dir_all(&dir);
+ // A crash before the copy is written again: the copy left behind is offered.
+ let (mut a, area) = saved_in(&dir);
+ leave(&mut a, &area);
+ drop(a);
+ assert_eq!(offered(&mut other_app(&dir)).len(), 1);
+ assert!(left_behind(&area).is_file());
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// A store in memory whose areas named `foreign-…` can't be locked by this account (as another
+/// account's folder in a shared recovery folder).
+#[derive(Default)]
+struct ForeignAreas(MemoryStore);
+
+impl RecoveryStore for ForeignAreas {
+ fn list(&self) -> std::result::Result, String> {
+ self.0.list()
+ }
+ fn read(&self, name: &str) -> std::result::Result, String> {
+ self.0.read(name)
+ }
+ fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
+ self.0.write(name, bytes)
+ }
+ fn remove(&self, name: &str) -> std::result::Result<(), String> {
+ self.0.remove(name)
+ }
+ fn location(&self) -> String {
+ self.0.location()
+ }
+ fn lock(&self, area: &str) -> std::result::Result {
+ if area.starts_with("foreign-") {
+ return Err(format!("{area}: belongs to another account"));
+ }
+ self.0.lock(area)
+ }
+ fn now(&self) -> Option {
+ self.0.now()
+ }
+}
+
+/// An entry ` /keeps-` in an area this account can't lock keeps nothing: a crashed
+/// app's copies in the left area are still offered.
+#[test]
+fn a_keeps_entry_in_an_area_that_cannot_be_locked_keeps_nothing() {
+ let store = Arc::new(ForeignAreas::default());
+ let app = || {
+ let mut s = Session::new();
+ s.recovery.set_store(store.clone());
+ s
+ };
+ let mut a = app();
+ a.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
+ a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
+ a.execute("file.recovery.save", &json!({})).unwrap();
+ let area = a.recovery.own_area().unwrap().to_string();
+ drop(a);
+ store.write(&format!("foreign-1/keeps-{area}"), b"").unwrap();
+ let mut b = app();
+ assert_eq!(offered(&mut b).len(), 1);
+ assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1);
+}
+
+/// An entry ` /keeps-` in a running app's area keeps a crashed app's copies in the
+/// left area until that app is gone too.
+#[test]
+fn a_keeps_entry_in_a_running_apps_area_keeps_the_copies() {
+ let store = Arc::new(MemoryStore::default());
+ let mut a = session_in(&store);
+ a.execute("file.recovery.save", &json!({})).unwrap();
+ let area = a.recovery.own_area().unwrap().to_string();
+ drop(a);
+ let mut c = session_in(&store);
+ c.execute("file.recovery.save", &json!({})).unwrap();
+ let keeper = c.recovery.own_area().unwrap().to_string();
+ store.write(&format!("{keeper}/keeps-{area}"), b"").unwrap();
+ let mut b = relaunch(&store);
+ assert!(offered(&mut b).is_empty());
+ assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0);
+ drop(c);
+ assert_eq!(offered(&mut b).len(), 2);
+}
+
+/// An entry ` /keeps-` in a folder whose lock file can't be opened keeps nothing.
+#[cfg(unix)]
+#[test]
+fn a_keeps_entry_in_a_folder_whose_lock_cannot_be_checked_keeps_nothing() {
+ let dir = std::env::temp_dir().join(format!("vc-recovery-keepsunlocked-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let (a, area) = saved_in(&dir);
+ drop(a);
+ std::fs::create_dir_all(dir.join("keeper").join(".lock")).unwrap();
+ std::fs::write(dir.join("keeper").join(format!("keeps-{area}")), b"").unwrap();
+ assert_eq!(offered(&mut other_app(&dir)).len(), 1);
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
/// A complex document (Data Recovery off for those) gets no new copy after the session left its
/// area: it keeps the copy it left there, and the heartbeat asks for it only once.
#[cfg(unix)]
diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs
index 22a0239fb2..9932055652 100644
--- a/crates/engine/src/tests/save.rs
+++ b/crates/engine/src/tests/save.rs
@@ -40,6 +40,50 @@ fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.node_count()
}
+/// A client confined by automation roots: a save without a path writes the document's own file, and
+/// when that file is outside the read roots the reply leaves out its `path` and names the files
+/// written beside it by their file names. A reply without a path leaves out a suggested `folder`
+/// outside them. A path inside the read roots, and one the client gives, stay whole.
+#[test]
+fn a_confined_save_leaves_out_paths_outside_the_read_root() {
+ use crate::file_access::{AutomationRoots, confine};
+
+ let d = dir("roots");
+ let (inside, outside) = (d.join("in"), d.join("out"));
+ for f in [&inside, &outside] {
+ std::fs::create_dir_all(f).unwrap();
+ }
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
+ rect(&mut s);
+ let own = path(&outside, "doc.vectorcraft");
+ s.execute("document.save", &json!({"path": own})).unwrap();
+ let person = s.execute("document.save", &json!({"separateArtboards": true})).unwrap();
+ let files: Vec = person["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
+ assert_eq!((files.len(), files[0].as_str()), (3, own.as_str()), "{person}");
+
+ let write_only = AutomationRoots::new(&[inside.as_path()], &[outside.as_path()]).unwrap();
+ let both = AutomationRoots::new(&[inside.as_path(), outside.as_path()], &[outside.as_path()]).unwrap();
+ let run = |s: &mut Session, roots: &Option>, id: &str, p: Value| {
+ confine(roots.as_ref(), || s.execute(id, &p)).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
+ };
+ let hidden = outside.to_string_lossy().into_owned();
+ let r = run(&mut s, &write_only, "document.save", json!({}));
+ assert!(r.get("path").is_none() && r["bytes"].as_u64().is_some_and(|n| n > 0), "{r}");
+ assert!(!r.to_string().contains(&hidden), "{r}");
+ let r = run(&mut s, &write_only, "document.save", json!({"separateArtboards": true}));
+ let names: Vec = files.iter().map(|f| json!(std::path::Path::new(f).file_name().unwrap().to_string_lossy())).collect();
+ assert_eq!((r.get("path"), r["files"].as_array()), (None, Some(&names)), "{r}");
+ // Inside the read roots, or given by the client: whole.
+ assert_eq!(run(&mut s, &both, "document.save", json!({}))["path"], json!(own));
+ assert_eq!(run(&mut s, &write_only, "document.save", json!({"path": own}))["path"], json!(own));
+ // A save without a path known suggests the document's folder.
+ let r = run(&mut s, &write_only, "file.saveCopy", json!({}));
+ assert!(r.get("folder").is_none() && r["name"] == "doc copy.vectorcraft" && r.get("dataBase64").is_some(), "{r}");
+ assert_eq!(run(&mut s, &both, "file.saveCopy", json!({}))["folder"], json!(hidden));
+ let _ = std::fs::remove_dir_all(d);
+}
+
#[test]
fn an_svg_path_writes_svg_and_save_writes_it_back() {
let d = dir("svg");
diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs
index d1d4155904..fd8f2c7e24 100644
--- a/crates/engine/src/tests/stylelib.rs
+++ b/crates/engine/src/tests/stylelib.rs
@@ -87,6 +87,87 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects()
assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err());
}
+#[test]
+fn painted_and_sketched_styles_set_every_effect_setting_and_reopen_applied() {
+ let mut s = session();
+ let list = run(&mut s, "graphicStyle.libraries", json!({}));
+ let libs = list["libraries"].as_array().unwrap();
+ let at = libs.iter().position(|l| l["id"] == "painted-sketched").expect("listed");
+ assert_eq!((libs[at]["name"].as_str(), libs[at]["count"].as_u64()), (Some("Painted and Sketched"), Some(7)));
+ assert_eq!(libs[at - 1]["id"], "textures");
+ let g = run(&mut s, "graphicStyle.library", json!({"library": "Painted and Sketched"}));
+ let listed: Vec<(&str, &str, Vec<&str>)> =
+ g["styles"].as_array().unwrap().iter().map(|x| (x["name"].as_str().unwrap(), x["fill"].as_str().unwrap(), names(&x["effects"]))).collect();
+ let want = [
+ ("Washed Blue", "#8ab9c9", "artistic.watercolor"),
+ ("Bristle Ochre", "#c4975a", "artistic.dryBrush"),
+ ("Pastel Heather", "#a78ca5", "artistic.roughPastels"),
+ ("Charcoal Shade", "#666666", "sketch.charcoal"),
+ ("Ink Contour", "#d9e2df", "brushStrokes.inkOutlines"),
+ ("Wet Ink", "#637b85", "brushStrokes.sumiE"),
+ ("Fine Pen", "#d8d4c6", "sketch.graphicPen"),
+ ];
+ assert_eq!(listed, want.iter().map(|&(n, f, e)| (n, f, vec![e])).collect::>());
+ // Every setting of each effect is given, with the catalog's key and kind of value.
+ let (_, lib) = cmd::stylelib::library(&s, "painted-sketched").unwrap();
+ for st in &lib.styles {
+ let e = &st.appearance.effects[0];
+ let defaults = vectorcraft_render::effects::effect_info(&e.id).unwrap().defaults;
+ let (set, all) = (e.params.as_object().unwrap(), defaults.as_object().unwrap());
+ let keys = |m: &serde_json::Map| m.keys().cloned().collect::>();
+ assert_eq!(keys(set), keys(all), "{}", st.name);
+ for (k, v) in set {
+ let kind = |v: &Value| (v.is_number(), v.is_string(), v.is_boolean());
+ assert_eq!(kind(v), kind(&all[k]), "{} / {k}", st.name);
+ }
+ }
+ let strokes: Vec<(&str, f64)> =
+ lib.styles.iter().filter(|g| g.appearance.stroke_paint() != Paint::None).map(|g| (g.name.as_str(), g.appearance.stroke_width())).collect();
+ assert_eq!(strokes, [("Bristle Ochre", 1.0), ("Ink Contour", 1.0)]);
+
+ // Each one adds and applies to a rectangle in one undo step.
+ let mut ids = vec![];
+ for (i, (name, fill, effect)) in want.iter().enumerate() {
+ let id = rect(&mut s, 10.0 + 25.0 * i as f64, 20.0, 20.0, 30.0);
+ let before = undo_len(&s);
+ let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": name, "apply": true, "ids": [id.0]}));
+ assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec![*name], Some(*name)));
+ assert_eq!(undo_len(&s), before + 1, "{name}");
+ let node = doc(&s).node(id).unwrap();
+ assert_eq!(node.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex(fill).unwrap()), "{name}");
+ assert_eq!(node.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), [*effect]);
+ assert_eq!(node.appearance.effects[0].params, lib.style(name).unwrap().appearance.effects[0].params, "{name}");
+ ids.push((id, *name));
+ }
+ // Adding one again reuses it: no new style, no undo step.
+ let before = (undo_len(&s), doc(&s).graphic_styles.len());
+ let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Wet Ink"}));
+ assert_eq!((names(&r["added"]), names(&r["existing"])), (vec![], vec!["Wet Ink"]));
+ assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before);
+ // An unknown style or library changes nothing.
+ assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "painted-sketched", "names": ["Wet Ink", "Nope"]})).is_err());
+ assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "painted-and-sketched", "name": "Wet Ink"})).is_err());
+ assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before);
+
+ // Saved and opened again, the styles keep their effects and the rectangles stay linked to them.
+ let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
+ run(&mut s, "document.open", json!({"name": "painted.vectorcraft", "dataBase64": b64}));
+ for (id, name) in &ids {
+ let d = doc(&s);
+ let style = d.graphic_style(name).unwrap();
+ assert!(style.same_look(lib.style(name).unwrap()), "{name}");
+ let node = d.node(*id).unwrap();
+ assert_eq!((node.extras().graphic_style, &node.appearance.effects), (Some(style.id), &style.appearance.effects), "{name}");
+ }
+
+ // With nothing selected, `apply` only adds; empty `names` adds the whole library in order.
+ run(&mut s, "file.new", json!({"width": 100, "height": 100}));
+ let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Fine Pen", "apply": true}));
+ assert!(r.get("applied").is_none() && doc(&s).graphic_style("Fine Pen").is_some());
+ let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "names": []}));
+ assert_eq!((names(&r["added"]), names(&r["existing"])), (want[..6].iter().map(|w| w.0).collect::>(), vec!["Fine Pen"]));
+}
+
#[test]
fn add_dedupes_names_as_one_undo_step() {
let mut s = session();
diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs
index 55ab34564a..57d6c23d06 100644
--- a/crates/engine/src/tests/swatchlib.rs
+++ b/crates/engine/src/tests/swatchlib.rs
@@ -798,6 +798,39 @@ fn a_pattern_library_adds_only_the_picked_pattern_and_it_undoes_and_saves() {
assert_eq!(back.swatch("Checkerboard").map(|w| &w.paint), doc(&s).swatch("Checkerboard").map(|w| &w.paint));
}
+/// Grit Textures is listed after Geometric Patterns. One of its patterns comes in with its swatch
+/// once, as one undo step that also takes both away, and both stay after a save and reopen.
+#[test]
+fn a_grit_texture_adds_once_undoes_and_saves() {
+ let mut s = session();
+ let list = run(&mut s, "swatch.library.list", json!({}));
+ let ids: Vec<&str> =
+ list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect();
+ assert_eq!(ids, ["geometric-patterns", "grit-textures"]);
+ let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"}));
+ let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
+ assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]);
+ let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
+ let empty = counts(doc(&s));
+ let undo = undo_len(&s);
+ let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]}));
+ assert_eq!(r["added"], json!(["Stipple"]));
+ assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1));
+ let def = doc(&s).pattern("Stipple").expect("the pattern comes with its swatch").clone();
+ assert!(def.art.len() >= 144, "{} marks", def.art.len());
+ assert!(matches!(doc(&s).swatch("Stipple").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Stipple"));
+ let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]}));
+ assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Stipple"])));
+ assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once");
+ run(&mut s, "edit.undo", json!({}));
+ assert_eq!(counts(doc(&s)), empty);
+ assert!(doc(&s).pattern("Stipple").is_none() && doc(&s).swatch("Stipple").is_none());
+ run(&mut s, "edit.redo", json!({}));
+ let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap();
+ assert_eq!(back.pattern("Stipple"), Some(&def));
+ assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint));
+}
+
/// A device name is saved with a `-` after it, which can be another library's own name: neither
/// library replaces the other, and saving either again replaces only itself.
#[test]
diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs
index b222f32107..9bc450f937 100644
--- a/crates/engine/src/tooling.rs
+++ b/crates/engine/src/tooling.rs
@@ -751,6 +751,27 @@ impl Session {
self.apply_actions(acts).map(drop)
}
+ /// The rulers' origin dragged out of their corner, whatever the tool, with the pointer at `p` in
+ /// document space (none off the canvas): where it goes, snapped as a drawn point is
+ /// ([`vectorcraft_tools::rulerguide::OriginDrag`]), the targets gathered on the drag's first
+ /// call. It changes nothing: the caller sets the origin where the button is released
+ /// (`document.setRulerOrigin`) and ends the drag ([`Session::end_ruler_origin_drag`]).
+ pub fn ruler_origin_drag(&mut self, p: Option, view: ViewInfo) -> Option {
+ let drag = match self.ruler_origin.take() {
+ Some(d) => Some(d),
+ None => self.with_tool_cx(view, |_, cx| Some(vectorcraft_tools::rulerguide::OriginDrag::new(cx))),
+ };
+ let Some(mut drag) = drag else { return p };
+ let at = self.with_tool_cx(view, |_, cx| drag.pointer(cx, p));
+ self.ruler_origin = Some(drag);
+ at
+ }
+
+ /// The drag of the rulers' origin ended, released or cancelled: its guides go.
+ pub fn end_ruler_origin_drag(&mut self) {
+ self.ruler_origin = None;
+ }
+
/// Time passed while the pointer button is held (`dt` seconds; see [`Tool::tick`]): the
/// desktop app ticks every frame of a press, agents with `holdMs` on a pointer event.
pub fn tool_tick(&mut self, dt: f64, view: ViewInfo) -> Result> {
@@ -1112,6 +1133,9 @@ impl Session {
v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx)));
self.ruler_guide = Some(g);
}
+ if let Some(d) = &self.ruler_origin {
+ v.extend(d.overlays());
+ }
let deferred_preview = self.unapplied_interaction_preview();
if let Some(d) = self.active() {
let w = self.prefs.perspective_widget;
diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs
index ba029dd3b1..db8696a10f 100644
--- a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs
+++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs
@@ -115,15 +115,27 @@ fn both_lexers_take_the_same_characters_into_names() {
assert!(checker_member("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}") && one_identifier("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}"));
for &c in NAME_CHARS {
let escape = format!("\\u{:04X}", u32::from(c));
+ let braced = format!("\\u{{{:X}}}", u32::from(c));
for (src, name) in [
(format!("a{c}b"), format!("a{c}b")),
(format!("{c}b"), format!("{c}b")),
(format!("a{escape}b"), format!("a{c}b")),
(format!("{escape}b"), format!("{c}b")),
+ (format!("a{braced}b"), format!("a{c}b")),
+ (format!("{braced}b"), format!("{c}b")),
] {
assert_eq!(checker_member(&src, &name), one_identifier(&src, &name), "{src:?} (U+{:04X})", u32::from(c));
}
}
+ // Braced escapes past U+FFFF and with leading zeros are read by both.
+ for (src, name) in [("\\u{1D49C}b", "\u{1D49C}b"), ("a\\u{0000000062}", "ab")] {
+ assert!(checker_member(src, name) && one_identifier(src, name), "{src:?}");
+ }
+ // Out of range, a surrogate and an empty one: the parser reads no identifier, and the checker's
+ // name ends before the escape.
+ for src in ["a\\u{110000}", "a\\u{D800}", "a\\u{}"] {
+ assert!(checker_member(src, "a") && !one_identifier(src, "a"), "{src:?}");
+ }
}
#[test]
diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs
index f2ba516b46..56bcf2892c 100644
--- a/crates/eps/src/ai_editing/tests.rs
+++ b/crates/eps/src/ai_editing/tests.rs
@@ -4366,6 +4366,19 @@ fn dynamic_text_is_written_at_its_drawn_sizes() {
assert!(!texts[0].dynamic_text);
let k = texts[0].xf.as_coeffs()[0];
assert_eq!(r3(texts[0].runs[0].style.size * k), r3(drawn));
+ // Type written as outlines (here on an open path whose ends meet) loses the setting too, and
+ // says so.
+ let mut doc = Document::new(600.0, 300.0);
+ let layer = doc.layers[0].id;
+ let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Grow", style(12.0, Color::BLACK));
+ let ends_meet = [(50.0, 200.0).into(), (550.0, 200.0).into(), (300.0, 50.0).into(), (50.0, 200.0).into()];
+ t.kind = TextKind::OnPath { path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&ends_meet, false)]), start: 0.0, end: None };
+ t.xf = Affine::IDENTITY;
+ t.dynamic_text = true;
+ let node = text_node(&mut doc, t);
+ doc.insert(Some(layer), usize::MAX, node).unwrap();
+ let out = export_ai_editing(&doc, &opts()).unwrap();
+ assert!(out.warnings.iter().any(|w| w == writer::TYPE) && out.warnings.iter().any(|w| w == writer::DYNAMIC_TEXT), "{:?}", out.warnings);
}
/// See-through type with the knockout shape flag comes back with the flag and writes no warning.
diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs
index afa6412561..dea4ad0c24 100644
--- a/crates/eps/src/ai_editing/writer.rs
+++ b/crates/eps/src/ai_editing/writer.rs
@@ -1257,6 +1257,10 @@ impl<'a> Writer<'a> {
return;
}
self.warn(TYPE);
+ // The outlines keep the sizes Dynamic Text draws the type at, but not the setting.
+ if matches!(&n.kind, NodeKind::Text(t) if t.dynamic_text) {
+ self.warn(DYNAMIC_TEXT);
+ }
let doc = self.doc;
let resolved = match &n.kind {
NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()),
diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs
index e256e305c2..5e058acaf6 100644
--- a/crates/eps/src/legacy_ai.rs
+++ b/crates/eps/src/legacy_ai.rs
@@ -97,6 +97,7 @@ pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines";
pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)";
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, manual kerning, optical kerning, small caps, superscript and subscript, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out";
+pub const DYNAMIC_TEXT: &str = "Dynamic Text is written as the sizes the type is drawn at (legacy .ai files don't have it)";
pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)";
pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)";
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs
index 43b28c934a..b9ffcd0305 100644
--- a/crates/eps/src/legacy_ai/tests.rs
+++ b/crates/eps/src/legacy_ai/tests.rs
@@ -1292,6 +1292,74 @@ fn a_font_height_reference_is_written_as_the_size_drawn() {
assert!((size - 20.0).abs() < 1e-6, "{size}");
}
+/// Type on a path with Dynamic Text is written at the sizes it is drawn at, with a warning that
+/// the setting itself is not written; without Dynamic Text there is no warning.
+#[test]
+fn dynamic_text_is_written_at_its_drawn_sizes_with_a_warning() {
+ let mut doc = typed();
+ assert!(!write(&doc).warnings.iter().any(|w| w == DYNAMIC_TEXT));
+ let id = texts(&doc).into_iter().find(|(_, t)| matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. })).unwrap().0.id;
+ let mut drawn = 0.0;
+ if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
+ t.runs[0].style.size = 4.0;
+ t.dynamic_text = true;
+ drawn = 4.0 * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale;
+ }
+ assert!(drawn > 4.0, "{drawn}");
+ let out = write(&doc);
+ assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings);
+ let imported = crate::import(&out.bytes).unwrap();
+ let (_, back) = texts(&imported.document).into_iter().find(|(_, t)| t.runs.iter().any(|r| r.text.contains("path"))).unwrap();
+ assert!(!back.dynamic_text);
+ assert!((back.runs[0].style.size - drawn).abs() < 1e-3, "{} vs {drawn}", back.runs[0].style.size);
+ // Type written as outlines loses the setting too, and says so.
+ if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
+ t.runs[0].text.push('\u{2192}');
+ }
+ let out = write(&doc);
+ assert!(out.warnings.iter().any(|w| w == TYPE) && out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings);
+ let out = write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() });
+ assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings);
+}
+
+/// Dynamic Text fits type whose size measures the cap height at the size the Character panel
+/// shows: 1,000 pt measuring a 0.7 em cap height on a long path grows to the 1,296 pt limit and is
+/// written at the em size drawn there, as text and as outlines.
+#[test]
+fn dynamic_text_with_a_font_height_reference_is_written_at_its_drawn_size() {
+ use vectorcraft_doc::{TextKind, TextObject};
+ use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font};
+ let db = vectorcraft_text::FontDb::global();
+ db.add_font(height_font(true).unwrap());
+ let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "HE", style(1000.0, red()));
+ let st = &mut t.runs[0].style;
+ st.font_family = HEIGHT_FAMILY.into();
+ st.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight;
+ let mut line = PathData::new(vec![]);
+ line.subpaths.push(vectorcraft_geom::SubPath::polyline(&[(0.0, 2000.0).into(), (100_000.0, 2000.0).into()], false));
+ t.kind = TextKind::OnPath { path: line, start: 0.0, end: None };
+ t.xf = Affine::IDENTITY;
+ t.dynamic_text = true;
+ let lay = vectorcraft_text::layout(db, &t);
+ assert!((lay.fit_scale - 1.296).abs() < 1e-9, "{}", lay.fit_scale);
+ let em = 1296.0 / 0.7;
+ let mut glyphs = kurbo::BezPath::new();
+ for g in &lay.glyphs {
+ glyphs.extend(g.outline.iter());
+ }
+ let want = glyphs.bounding_box();
+ let mut doc = Document::new(300.0, 200.0);
+ let layer = doc.layers[0].id;
+ let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
+ doc.insert(Some(layer), usize::MAX, n).unwrap();
+ let back = crate::import(&write(&doc).bytes).unwrap().document;
+ let size = texts(&back)[0].1.runs[0].style.size;
+ assert!((size - em).abs() < 1e-3, "{size} vs {em}");
+ let back = crate::import(&write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }).bytes).unwrap().document;
+ let b = children(&children(&back.layers[0])[0])[0].visual_bounds().unwrap();
+ assert!((b.height() - want.height()).abs() < 0.05 && (b.width() - want.width()).abs() < 0.05, "{b:?}, laid out at {want:?}");
+}
+
/// Characters with Auto leading are written with their paragraph's Auto Leading share of the
/// size: 18 pt type in paragraphs at 150% is written with 27 pt leading.
#[test]
diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs
index 8f8fc4ccf2..3373330892 100644
--- a/crates/eps/src/legacy_ai/writer.rs
+++ b/crates/eps/src/legacy_ai/writer.rs
@@ -3,8 +3,8 @@ use super::encoding::{
unit, writable,
};
use super::{
- Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient,
- GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
+ Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, DYNAMIC_TEXT, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule,
+ Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData,
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
@@ -733,15 +733,15 @@ impl<'a> Writer<'a> {
// ---------- type ----------
/// Type: a text object when the format can hold it as text, else its outlines (type with
- /// rotated characters among them, which keeps their rotation). A run whose
- /// font size measures a height other than the em box is written at the em size it is drawn
- /// at ([`vectorcraft_text::FontDb::font_heights_resolved`]).
+ /// rotated characters among them, which keeps their rotation).
fn text(&mut self, t: &TextObject) {
if !invertible(t.xf) {
return;
}
- let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t);
- let t = &*resolved;
+ // Written as text or as outlines, the type keeps its drawn sizes but not the setting.
+ if t.dynamic_text && matches!(t.kind, TextKind::OnPath { .. }) {
+ self.warn(DYNAMIC_TEXT);
+ }
if !self.outline_text {
if writable(self.doc, t) {
return self.text_object(t);
@@ -759,14 +759,18 @@ impl<'a> Writer<'a> {
/// A text object (§15): `type To`, the text path (`a b c d tx ty startPt Tp`, an area's frame
/// or the path type runs along, `TP`), then each run's style and characters, `TO`. The matrix
/// takes text space (y up, the first baseline's start for point type) to art space; the frame
- /// and path are in art space, as the specification's examples have them (§15.5).
+ /// and path are in art space, as the specification's examples have them (§15.5). Type that
+ /// Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in, fitted at
+ /// the sizes the Character panel shows; then a run whose font size measures a height other
+ /// than the em box is written at the em size it is drawn at
+ /// ([`vectorcraft_text::FontDb::font_heights_resolved`]).
fn text_object(&mut self, t: &TextObject) {
if lost_attributes(t) {
self.warn(TYPE_ATTRIBUTES);
}
- // Type that Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in.
let fitted = vectorcraft_text::drawn(t);
- let t = &*fitted;
+ let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(&fitted);
+ let t = &*resolved;
let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let (kind, path, start) = match &t.kind {
TextKind::Point => (0, None, 0.0),
diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs
index 2f81bf7666..bfb4de5eac 100644
--- a/crates/js/src/builtins/array.rs
+++ b/crates/js/src/builtins/array.rs
@@ -13,6 +13,7 @@ use super::{
};
use crate::heap::{Class, NativeKind, ObjId};
use crate::interp::{Abrupt, Interp, JsResult, strict_eq};
+use crate::string_builtins::flat_charged;
use crate::value::{Str, Value, to_uint32};
pub(super) fn install(interp: &mut Interp) -> JsResult<()> {
@@ -278,17 +279,20 @@ fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Int
if len == 0 {
return Ok(Value::str(""));
}
- let flat = sep.flat();
- let sep = &flat[..];
- // A result that cannot fit is refused before any element is read.
- let least = (sep.len() as u64).saturating_mul(u64::from(len - 1));
- if least > (interp.limits.heap_bytes / 4) as u64 {
- return Err(Abrupt::Limit(crate::LimitKind::Heap));
- }
- interp.join_stack.push(o);
- let r = join_elements(interp, o, len, sep, item);
- interp.join_stack.pop();
- r
+ // The separator stays rooted for the whole join, so its flattened copy stays counted.
+ interp.rooted(&[Value::String(sep.clone())], |interp| {
+ let flat = flat_charged(interp, &sep)?;
+ let sep = &flat[..];
+ // A result that cannot fit is refused before any element is read.
+ let least = (sep.len() as u64).saturating_mul(u64::from(len - 1));
+ if least > (interp.limits.heap_bytes / 4) as u64 {
+ return Err(Abrupt::Limit(crate::LimitKind::Heap));
+ }
+ interp.join_stack.push(o);
+ let r = join_elements(interp, o, len, sep, item);
+ interp.join_stack.pop();
+ r
+ })
}
fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(&mut Interp, Value) -> JsResult>) -> JsResult {
@@ -301,7 +305,10 @@ fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(
let Some(s) = item(interp, v)? else { continue };
out.push_repeated(interp, sep, k.saturating_sub(seps))?;
seps = k;
- out.push_units(interp, &s.flat())?;
+ interp.rooted(&[Value::String(s.clone())], |interp| {
+ let units = flat_charged(interp, &s)?;
+ out.push_units(interp, &units)
+ })?;
}
out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?;
Ok(out.finish(interp))
@@ -472,7 +479,7 @@ fn unshift(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul
with_this(interp, &this, |interp, o| {
let len = u64::from(length_of(interp, o)?);
let count = args.len() as u64;
- // With no arguments each element is still read and written back in place (steps 6 and 7).
+ // With no arguments each element is still read and written back in place (step 6).
let maps = vec![Map::Shift(-1), Map::Shift(count as i64 - 1)];
let mut it = Indices::new(interp, o, 1, len + 1, false, maps)?;
while let Some(k) = it.next(interp)? {
diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs
index 4375303fe0..364ec99696 100644
--- a/crates/js/src/builtins/json.rs
+++ b/crates/js/src/builtins/json.rs
@@ -13,6 +13,7 @@ use std::collections::HashSet;
use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer};
use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes};
use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult};
+use crate::string_builtins::flat_charged;
use crate::value::{Str, Value, number_to_str};
pub(super) fn install(interp: &mut Interp) -> JsResult<()> {
@@ -473,7 +474,7 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult s.gap = t.flat().iter().take(10).copied().collect(),
+ Value::String(t) => s.gap = flat_charged(interp, &t)?.iter().take(10).copied().collect(),
_ => {}
}
Ok(s)
@@ -632,7 +633,10 @@ fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResul
Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }),
Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()),
Value::Number(_) => out.push_str(interp, "null"),
- Value::String(s) => write_quoted(interp, out, &s.flat()),
+ Value::String(s) => interp.rooted(std::slice::from_ref(v), |i| {
+ let units = flat_charged(i, s)?;
+ write_quoted(i, out, &units)
+ }),
Value::Undefined | Value::Object(_) => Ok(()),
}
}
diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs
index f5e2ce2ae6..22a365b615 100644
--- a/crates/js/src/interp.rs
+++ b/crates/js/src/interp.rs
@@ -27,6 +27,7 @@ use crate::heap::{
ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, ENV_BYTES, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OBJECT_BYTES, OPEN, ObjId,
Object, PROP_BYTES, Prop, PropMap, ROPE_BYTES, Roots, WRITABLE, code_bytes, key, string_bytes,
};
+use crate::string_builtins::flat_charged;
use crate::value::{Str, Value, array_index, index_key, number_to_str, string_to_number, to_int32, to_uint32};
use crate::{LimitKind, Limits, Options};
@@ -205,6 +206,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,
+ /// Bytes of the copies [`Self::get_own_property`] made flattening the rope of a String
+ /// object, which the heap's total adds before the next limit check.
+ flattened: std::cell::Cell,
+ /// Fuel for the units those copies took and the rope nodes it read in place, which the next
+ /// step takes.
+ pub(crate) flattened_fuel: std::cell::Cell,
/// The global code's default XML namespace URI (ECMA-357 section 12.1).
pub(crate) xml_default_uri: Key,
/// Set by the host to end the current slice of a resumable run at the next safe point.
@@ -450,6 +457,8 @@ impl Interp {
files: None,
date_host: crate::date_builtins::DateHost::default(),
held: Rc::default(),
+ flattened: std::cell::Cell::new(0),
+ flattened_fuel: std::cell::Cell::new(0),
xml_default_uri: Rc::from(Vec::new()),
pause: Rc::default(),
in_slice: false,
@@ -569,6 +578,7 @@ impl Interp {
fn tick(&mut self) -> JsResult<()> {
self.use_fuel()?;
+ crate::regexp_vm::use_fuel_n(self, self.flattened_fuel.replace(0))?;
self.settle_held();
if self.heap.bytes > self.gc_threshold {
self.collect_garbage();
@@ -595,10 +605,12 @@ impl Interp {
Ok(())
}
- /// Takes the bytes that dropped [`HeldBytes`] released off the heap's total.
+ /// Takes the bytes that dropped [`HeldBytes`] released off the heap's total, and adds the
+ /// bytes of the ropes [`Self::get_own_property`] flattened.
fn settle_held(&mut self) {
let released = self.held.released.replace(0);
- self.heap.bytes = self.heap.bytes.saturating_sub(released);
+ let flattened = self.flattened.replace(0);
+ self.heap.bytes = self.heap.bytes.saturating_sub(released).saturating_add(flattened);
}
/// Collects garbage now and sets when the next collection runs.
@@ -680,6 +692,7 @@ impl Interp {
self.heap.collect(&roots);
// The collection counts the live total again, so bytes already released are not in it.
self.held.released.set(0);
+ self.flattened.set(0);
self.heap.bytes = self.heap.bytes.saturating_add(self.held_bytes).saturating_add(self.held.now.get());
}
@@ -1235,7 +1248,33 @@ impl Interp {
if let Some(i) = array_index(k)
&& (i as usize) < s.len()
{
- let unit = s.flat().get(i as usize).copied().unwrap_or(0);
+ let unit = if s.is_rope() {
+ let owed = self.flattened_fuel.get();
+ let flattened = self.flattened.get().saturating_add(string_bytes(s.len()));
+ if self.heap.bytes.saturating_add(flattened) > self.limits.heap_bytes {
+ // The copy does not fit without a collection, which needs `&mut self`:
+ // the unit is read along the rope instead, a unit of fuel per node.
+ let Some((unit, visited)) = s.unit_at(i as usize, self.fuel.saturating_sub(owed)) else {
+ return Err(Abrupt::Limit(LimitKind::Fuel));
+ };
+ self.flattened_fuel.set(owed.saturating_add(visited));
+ unit
+ } else {
+ // Flattening copies the units. The run ends here when the fuel for
+ // the copy is not left; otherwise the next step takes it, and the
+ // next limit check adds the copy's bytes.
+ let fuel = owed.saturating_add((s.len() / crate::string_builtins::UNITS_PER_FUEL) as u64 + 1);
+ if fuel > self.fuel {
+ return Err(Abrupt::Limit(LimitKind::Fuel));
+ }
+ self.flattened_fuel.set(fuel);
+ self.flattened.set(flattened);
+ s.flat().get(i as usize).copied()
+ }
+ } else {
+ s.flat().get(i as usize).copied()
+ }
+ .unwrap_or(0);
return Ok(Some(Prop::Data { value: Value::String(Str::from_units(vec![unit])), flags: ENUMERABLE }));
}
}
@@ -1312,7 +1351,7 @@ impl Interp {
if let Some(i) = array_index(k)
&& (i as usize) < s.len()
{
- let unit = s.flat().get(i as usize).copied().unwrap_or(0);
+ let unit = flat_charged(self, s)?.get(i as usize).copied().unwrap_or(0);
return Ok(Value::String(Str::from_units(vec![unit])));
}
}
@@ -2420,7 +2459,7 @@ impl Interp {
let strict = self.strict();
self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?;
self.prepare_source(s.len())?;
- let units = s.flat();
+ let units = self.flat_reserved(s)?;
let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?;
self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?;
let frame = self.frame()?;
@@ -2585,7 +2624,7 @@ impl Interp {
Value::Null => 0.0,
Value::Bool(b) => f64::from(u8::from(*b)),
Value::Number(n) => *n,
- Value::String(s) => string_to_number(&s.flat()),
+ Value::String(s) => string_to_number(&flat_charged(self, s)?),
Value::Object(_) => {
let p = self.to_primitive(v, Hint::Number)?;
return self.to_number(&p);
@@ -2619,19 +2658,28 @@ impl Interp {
#[allow(clippy::wrong_self_convention)]
pub(crate) fn to_key(&mut self, v: &Value) -> JsResult {
match v {
- Value::String(s) => {
- let rope = s.is_rope();
- let flat = s.flat();
- if rope {
- self.heap.bytes += string_bytes(flat.len());
- }
- Ok(flat)
- }
+ Value::String(s) => self.flat_reserved(s),
Value::Number(n) if *n >= 0.0 && *n < 4_294_967_295.0 && n.fract() == 0.0 => Ok(index_key(*n as u32)),
- _ => Ok(self.to_str(v)?.flat()),
+ _ => {
+ let s = self.to_str(v)?;
+ self.flat_reserved(&s)
+ }
}
}
+ /// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that
+ /// copy's bytes are reserved and added to the heap first, without taking fuel. The rope stays
+ /// rooted while the copy is reserved; everything else the caller still needs must be
+ /// reachable from the roots.
+ fn flat_reserved(&mut self, s: &Str) -> JsResult {
+ if s.is_rope() {
+ let bytes = string_bytes(s.len());
+ self.rooted(&[Value::String(s.clone())], |i| i.reserve_heap(bytes))?;
+ self.heap.bytes = self.heap.bytes.saturating_add(bytes);
+ }
+ Ok(s.flat())
+ }
+
/// ToObject (section 9.9).
#[allow(clippy::wrong_self_convention)]
pub(crate) fn to_object(&mut self, v: &Value) -> JsResult {
@@ -2754,7 +2802,11 @@ impl Interp {
(self.to_primitive(x, Hint::Number)?, py)
};
if let (Value::String(a), Value::String(b)) = (&px, &py) {
- return Ok(Some(*a.flat() < *b.flat()));
+ // Both stay rooted, so the first copy is still counted while the second is made.
+ return self.rooted(&[px.clone(), py.clone()], |i| {
+ let (a, b) = (flat_charged(i, a)?, flat_charged(i, b)?);
+ Ok(Some(*a < *b))
+ });
}
let nx = self.to_number(&px)?;
let ny = self.to_number(&py)?;
@@ -2779,7 +2831,9 @@ impl Interp {
| (Value::String(_), Value::String(_))
| (Value::Bool(_), Value::Bool(_))
| (Value::Object(_), Value::Object(_)) => return Ok(strict_eq(&x, &y)),
- (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => return Ok(*n == string_to_number(&s.flat())),
+ (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => {
+ return Ok(*n == string_to_number(&flat_charged(self, s)?));
+ }
(Value::Bool(v), _) => x = Value::Number(f64::from(u8::from(*v))),
(_, Value::Bool(v)) => y = Value::Number(f64::from(u8::from(*v))),
(Value::Number(_) | Value::String(_), Value::Object(_)) => y = self.to_primitive(&y, Hint::Default)?,
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index 7df9953a67..cad290f1af 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -41,9 +41,11 @@
//! With [`Options::e4x`], the parser also reads E4X (ECMA-357, second edition, December 2005):
//! XML and XMLList literals, the `.@`, `..`, `::` and `.( )` operators, `for each … in` and
//! `default xml namespace`; the realm then provides `XML`, `XMLList`, `Namespace`, `QName`,
-//! `isXMLName` and `setDefaultXMLNamespace`. Without the option, the language and the built-ins are
+//! `isXMLName` and `setDefaultXMLNamespace`, and XML and XMLList values have an `xpath` method that
+//! evaluates an XPath 1.0 expression. Without the option, the language and the built-ins are
//! those of ECMAScript 5.1 above. XML trees live in the heap, and parsing, copying, comparing and
-//! serializing them use no native recursion and take fuel for each node.
+//! serializing them, and evaluating XPath on them, use no native recursion and take fuel for each
+//! node.
#![forbid(unsafe_code)]
pub mod ast;
@@ -70,6 +72,7 @@ mod token;
mod value;
mod xml;
mod xml_builtins;
+mod xpath;
pub use ast::{
AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property,
diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs
index b17d6a2770..b1651d1291 100644
--- a/crates/js/src/realm.rs
+++ b/crates/js/src/realm.rs
@@ -231,6 +231,7 @@ impl Realm {
return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 });
}
interp.fuel = interp.limits.fuel;
+ interp.flattened_fuel.set(0);
interp.source_units = 0;
interp.throw_site = None;
interp.last_thrown = Value::Undefined;
diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs
index b3758bdf54..eed08f96bd 100644
--- a/crates/js/src/string_builtins.rs
+++ b/crates/js/src/string_builtins.rs
@@ -98,7 +98,7 @@ fn check_string_room(interp: &mut Interp, len: usize) -> JsResult<()> {
/// Pushes `item` onto `list`, first holding the bytes of the list's new capacity when it must
/// grow.
-fn push_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, item: T) -> JsResult<()> {
+pub(crate) fn push_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, item: T) -> JsResult<()> {
if list.len() == list.capacity() {
let more = list.capacity().max(4);
held.add(interp, more.saturating_mul(size_of::()))?;
@@ -109,10 +109,11 @@ fn push_held