Merge main into fix/read-roots-document-replies

This commit is contained in:
dotCooCoo
2026-11-12 01:13:38 -08:00
170 changed files with 11762 additions and 533 deletions
+1
View File
@@ -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`) | |
+1
View File
@@ -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).
+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-graduation-cap"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" />
<path d="M22 10v6" />
<path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" />
</svg>

After

Width:  |  Height:  |  Size: 497 B

+84
View File
@@ -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<GraphicStyle> {
]
}
/// 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<GraphicStyle> {
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<GraphicStyle> {
@@ -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();
+9
View File
@@ -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 {
'.'
}
+19 -1
View File
@@ -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<EffectInfo> {
"{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…",
+7 -5
View File
@@ -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;
+91 -36
View File
@@ -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<Item = &'a PathData>, 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(&params);
let options = options_of(kind, &params);
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<Option<Node>, 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<Vec<Arc<Node>>, 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<f64>, 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<Node> {
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<crate::SolidMesh, String> {
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<Vec<crate::
.appearance
.effects
.iter()
.position(|e| e.visible && e.id == REVOLVE)
.position(|e| e.visible && is_revolve(&e.id))
.ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?;
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
let mut shapes = members(n, "Revolve")?;
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?;
let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
let options = shared_options(&shapes, fx_options(fx))?;
let mut out = vec![];
for s in &shapes {
let Some(item) = surface_item(&s.items) else { continue };
@@ -491,7 +543,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
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<Node> {
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<Node> {
.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<Node> {
// 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<Node> {
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<Node> {
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 {
+176
View File
@@ -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, &params).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<Color> {
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}");
}
}
}
+31 -16
View File
@@ -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<usize>) -> 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<ShadowAndSurface
/// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on
/// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the
/// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow
/// off, for Extrude & Bevel (Classic) or with no shapes. A stroke's solid that is not built (past
/// off, for Extrude & Bevel (Classic) and Rotate (Classic) or with no shapes. A stroke's solid that is not built (past
/// the limits) casts nothing, as it draws nothing; any other solid that is not built is an error.
/// When a member's opacity mask or blending mode applies to its solids ([`masked`]), it applies to
/// the part of the shadow they cast too ([`shadow::scoped_art`]).
fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<Cast<'a>>, 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<Vec<Arc<Node>>> {
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<Vec<Arc<Nod
if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) {
return None;
}
out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored))));
let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) };
out.push((s.scopes.as_slice(), Arc::new(art)));
}
}
return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() });
@@ -1001,7 +1016,7 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize)
let view = view_options(&p);
let mut out = vec![];
let mut keep = |out: &mut Vec<SolidMesh>, 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);
+40 -5
View File
@@ -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>) -> 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<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node {
pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node {
let shading = classic_shading(p);
let lights = lights(p);
let shade = shading_color(p);
+1 -1
View File
@@ -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<Cow<'a, Node>, 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)
}
+77
View File
@@ -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<Node> {
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}");
}
}
+17
View File
@@ -507,6 +507,23 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result<Opt
Some(v) => 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<Option<Vec<NodeId>>> {
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)
+7 -4
View File
@@ -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<CommandSpec> {
"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<Point> {
/// `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<TextObject> {
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<TextObject> {
(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();
+20 -2
View File
@@ -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<CommandSpec> {
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 `<name> 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",
+225 -3
View File
@@ -239,7 +239,7 @@ pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
&& 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<Value> {
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<String>) {
pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> {
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<String> {
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<Value> {
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<Value> {
@@ -483,6 +494,217 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
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<vectorcraft_geom::Rect>)> {
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<vectorcraft_geom::Rect>) {
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<Node> {
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<Value> {
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<vectorcraft_geom::Rect> {
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<vectorcraft_geom::Rect>)> {
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<vectorcraft_geom::Rect>)> = 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<Value> {
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<Value> {
let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?);
let ids: Vec<NodeId> = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect();
+17 -4
View File
@@ -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<CommandSpec> {
"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<Value> {
let mut fonts = BTreeSet::new();
let mut images: BTreeMap<String, Value> = BTreeMap::new();
let mut image_rows: Vec<ImageRow> = 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<String, bool> = 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<Value> {
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, .. } => {
+32 -14
View File
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<u64, Arc<Node>> {
let mut out = std::collections::HashMap::new();
if ids.is_empty() {
return out;
}
let ids: std::collections::HashSet<u64> = 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<Value> {
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<u64> = 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<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect());
// Only the objects with a stage have anything to refresh.
let ids: Option<Vec<u64>> = ids.map(|ids| {
let mut ids: Vec<u64> = 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<Value> {
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();
@@ -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());
}
@@ -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())
+6 -4
View File
@@ -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)]
+23
View File
@@ -42,6 +42,29 @@ pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
(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<()> {
+4 -4
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-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 <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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
),
+38 -2
View File
@@ -1034,11 +1034,47 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value>
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<CommandSpec> {
"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 <name>-<artboard>.<ext> 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 <name>_<artboard>.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 <name>-<artboard>.<ext> 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 <name>_<artboard>.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)
),
+105 -4
View File
@@ -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<Value> {
// 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` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) 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<String, Value>) -> Map<String, Value> {
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<Value> {
Ok(json!({ "presets": preset_list(&s.prefs.screen_export_presets) }))
let saved: Vec<ScreenExportPreset> = 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<Value> {
return Err(too_many());
}
let count = presets.len();
let presets: Vec<ScreenExportPreset> =
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<String, Value>)
/// `document.exportSettings`: the Export for Screens settings the document last exported with.
pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> {
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
+13
View File
@@ -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);
@@ -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<std::sync::Arc<AutomationRoots>>, 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<std::sync::Arc<AutomationRoots>>, 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);
+23 -16
View File
@@ -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<CommandSpec> {
"Package",
[],
None,
"{folder?, name?: (default: \"<document> 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 (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.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: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder",
"{folder?, name?: (default: \"<document> 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 (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.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: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) 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<String>, 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<String>, 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()));
+160 -2
View File
@@ -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<Rect> = 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");
}
}
@@ -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",
+165 -14
View File
@@ -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 `<new area>/keeps-<old area>` ([`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 `<area>/keeps-<left area>`: the app holding `<area>` still keeps the copies
/// it left in `<left area>` ([`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<Vec<String>, 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<u64, (Arc<dyn RecoveryStore>, String)>,
/// The `<own area>/keeps-<left area>` entries this session wrote in its area ([`mark_left`]).
marked: BTreeSet<String>,
}
impl Recovery {
@@ -553,6 +616,15 @@ fn beating(store: &dyn RecoveryStore, area: &str, stale: i64, mine: Option<i64>)
/// beat only lets another app offer the copies once three intervals have passed.
pub fn heartbeat(s: &mut Session) -> Vec<u64> {
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 `<own area>/keeps-<left area>`, 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<String> = 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<String> = 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<dyn RecoveryStore>) -> Result<String>
}
};
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
/// `<its area>/keeps-<area>` ([`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<Owner> {
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.
+26 -2
View File
@@ -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<Vec<vectorcraft_doc::NodeId>> {
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<Value> {
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<Value> {
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);
+57 -20
View File
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<L: LibraryFile> Libraries<L> {
.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/<path>`) whose file the request being served may not read, each
/// with the id a reply shows instead: `loaded/<file name>`, 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/<file name> (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/<file name> (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<Item = String>) -> 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<Item = Strin
let mut taken = BTreeSet::new();
for ids in [session, others] {
for id in ids {
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path));
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (concealed_file_name(path), path));
match name {
Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)),
_ => {
@@ -502,16 +519,32 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
}
}
}
let mut assigned = s.shown_library_ids.lock().unwrap_or_else(|e| e.into_inner());
let mut kept = vec![None; hidden.len()];
for (slot, (id, _)) in kept.iter_mut().zip(&hidden) {
if let Some(shown) = assigned.get(id).filter(|shown| !taken.contains(*shown)) {
taken.insert(shown.clone());
*slot = Some(shown.clone());
}
}
// A new id is never one given to another library earlier in the session.
let mut given: BTreeSet<String> = 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<Value> {
"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`:
+36 -8
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
fn parse_stops(p: &Value) -> Result<Vec<TabStop>> {
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<Vec<TabStop>> {
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<Value> {
#[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<Value> = (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();
+56 -16
View File
@@ -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<CommandSpec> {
let mut v = style_cmds!(
@@ -148,13 +148,13 @@ pub fn specs() -> Vec<CommandSpec> {
"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<String, Value>, kept: Option<&Map<String, Value>>, 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<String, Value>, kept: Option<&Map<Str
}
}
if changed("tabs") {
let kept_tabs: Vec<f64> = 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<String, Value>>) -> Vec<TabStop> {
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<String, Value>, kept: Option<&Map<String, Value>>) {
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<Value> {
/// 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<TextStyleDef>, Vec<String>) {
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();
+74
View File
@@ -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<Value> =
(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::<Vec<_>>()), "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, &params).unwrap_err().to_string();
assert!(error.contains(why), "{cmd}: {error}");
}
}
let many = Value::from((0..257).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>());
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}");
}
}
+14
View File
@@ -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))?;
+5
View File
@@ -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<std::collections::BTreeMap<String, String>>,
/// 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<Option<(usize, cmd::gradient::StopOwner)>>,
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
/// The rulers' origin being dragged out of their corner ([`Session::ruler_origin_drag`]).
pub(crate) ruler_origin: Option<vectorcraft_tools::rulerguide::OriginDrag>,
/// 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
+2
View File
@@ -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,
+7 -1
View File
@@ -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)?;
+43 -1
View File
@@ -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();
+361
View File
@@ -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<NodeId> {
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<Node> = 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<Rect> = 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();
+20
View File
@@ -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();
@@ -94,10 +94,49 @@ fn run_fresh(
params: &serde_json::Value,
) -> (std::result::Result<serde_json::Value, String>, Option<std::sync::Arc<vectorcraft_doc::Document>>) {
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.<id>`) 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<bool, St
with_ids["ids"] = json!(list);
let (a, b) = (given.execute(cmd, &with_ids), selected.execute(cmd, &params));
let same_result = match (&a, &b) {
(Ok(x), Ok(y)) => x == y,
(Ok(x), Ok(y)) => same_without_plugin_effects(cmd, x, y),
(Err(x), Err(y)) => x.to_string() == y.to_string(),
_ => false,
};
+51 -5
View File
@@ -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<Value> = 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<Value> = 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 `<folder>/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<Value> = 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);
}
+35
View File
@@ -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() {
+97
View File
@@ -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]
+92
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>()).collect::<Vec<_>>();
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::<Vec<_>>()
};
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=<folder>` saves a test live effect to try in the app
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
+235
View File
@@ -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<Vec<String>, String> {
self.0.list()
}
fn read(&self, name: &str) -> std::result::Result<Vec<u8>, 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<cmd::recovery::Lock, String> {
if area.starts_with("foreign-") {
return Err(format!("{area}: belongs to another account"));
}
self.0.lock(area)
}
fn now(&self) -> Option<i64> {
self.0.now()
}
}
/// An entry `<area>/keeps-<left area>` 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 `<area>/keeps-<left area>` 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 `<area>/keeps-<left area>` 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)]
+44
View File
@@ -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<String> = 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<std::sync::Arc<AutomationRoots>>, 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<Value> = 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");
+81
View File
@@ -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::<Vec<_>>());
// 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<String, Value>| m.keys().cloned().collect::<Vec<_>>();
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::<Vec<_>>(), [*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<_>>(), vec!["Fine Pen"]));
}
#[test]
fn add_dedupes_names_as_one_undo_step() {
let mut s = session();
+33
View File
@@ -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]
+24
View File
@@ -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<Point>, view: ViewInfo) -> Option<Point> {
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<Vec<UiRequest>> {
@@ -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;
@@ -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]
+13
View File
@@ -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.
+4
View File
@@ -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()),
+1
View File
@@ -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)";
+68
View File
@@ -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]
+14 -10
View File
@@ -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),
+20 -13
View File
@@ -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<Option<Str>>) -> JsResult<Value> {
@@ -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)? {
+6 -2
View File
@@ -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<Se
let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize;
s.gap = vec![u16::from(b' '); n];
}
Value::String(t) => 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(()),
}
}
+71 -17
View File
@@ -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<HeldCount>,
/// 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<usize>,
/// 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<u64>,
/// 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<Key> {
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<Key> {
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<ObjId> {
@@ -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)?,
+5 -2
View File
@@ -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,
+1
View File
@@ -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;
+5 -4
View File
@@ -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<T>(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec<T>, item: T) -> JsResult<()> {
pub(crate) fn push_held<T>(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec<T>, item: T) -> JsResult<()> {
if list.len() == list.capacity() {
let more = list.capacity().max(4);
held.add(interp, more.saturating_mul(size_of::<T>()))?;
@@ -109,10 +109,11 @@ fn push_held<T>(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec<T>, it
}
/// 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, and its units charged as fuel, first.
/// bytes are reserved and added to the heap, and its units charged as fuel, first. The rope stays
/// rooted while the copy is reserved, so what it holds is still counted.
pub(crate) fn flat_charged(interp: &mut Interp, s: &Str) -> JsResult<Rc<[u16]>> {
if s.is_rope() {
reserve_units(interp, s.len())?;
interp.rooted(&[Value::String(s.clone())], |interp| reserve_units(interp, s.len()))?;
}
Ok(s.flat())
}
@@ -179,7 +180,7 @@ fn clamp(n: f64, len: usize) -> usize {
/// The first index at `from` or after where `needle` occurs in `hay`. Every position tried takes
/// fuel.
fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult<Option<usize>> {
pub(crate) fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult<Option<usize>> {
if needle.len() > hay.len() {
return Ok(None);
}
+125
View File
@@ -309,6 +309,131 @@ fn flattening_a_rope_counts_against_the_heap() {
}
}
#[test]
fn operators_and_builtins_count_a_flattened_rope_against_the_heap() {
// Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap.
let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';";
let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() };
let not_stopped: Vec<_> =
["X < 'a'", "X == 1", "+X", "X[0]", "({})[{ toString: function () { return X; } }]", "JSON.stringify(0, null, X)", "[1].join(X)"]
.into_iter()
.filter(|read| {
let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u"));
let result = Realm::with_options(Options::default(), limits.clone()).run(&src);
!matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap))
})
.collect();
assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}");
}
#[test]
fn a_flattened_temporary_stays_counted_while_the_next_copy_is_made() {
// Ropes of 262,145 units, each about half the heap once flat: one fits, two don't. Each is
// made by a function, so nothing but the native code holds it while it is flattened.
let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; }";
let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() };
let run = |read: &str| Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0"));
assert!(run("f('a')[0]").is_ok(), "one flattened copy fits");
let not_stopped: Vec<_> = [
"({ valueOf: function () { return f('a'); } }) < ({ valueOf: function () { return f('b'); } })",
"JSON.stringify({ toJSON: function () { return f('a'); } })",
"[1, 2].join({ toString: function () { return f('a'); } })",
"[{ toString: function () { return f('a'); } }].join('')",
]
.into_iter()
.filter(|read| !matches!(run(read), Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)))
.collect();
assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}");
}
#[test]
fn a_converted_rope_counts_what_it_holds_while_its_copy_is_made() {
// `mk` returns a rope whose left half is a flat string of about half the heap, held by
// nothing else. Its flattened copy is the other half, so the two don't fit together.
let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; } \
function mk() { var a = f('a'); a[0]; return a + 'b'; }";
let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() };
let not_stopped: Vec<_> = [
"+({ valueOf: mk })",
"({})[{ toString: mk }]",
"({ valueOf: mk }) == 1",
"++({ get x() { return mk(); } }).x",
"JSON.stringify(0, null, (function () { var o = new String(''); o.toString = mk; return o; })())",
]
.into_iter()
.filter(|read| {
let result = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0"));
!matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap))
})
.collect();
assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}");
}
#[test]
fn a_string_object_counts_its_flattened_rope_against_the_heap() {
// The same three ropes, each wrapped in a String object and read by index.
let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z'; \
String.prototype.first = function () { return this[0]; };";
let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() };
// Wrapping alone reads no units, so it makes no copy.
let lengths =
Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} Object(s).length + new String(t).length + Object(u).length"));
assert_eq!(lengths, Ok(JsValue::Number(1_572_866.0)));
// Two copies fit. The third read finds no room for its copy and reads the unit along the
// rope, so the live heap stays within the limit.
for (read, expected) in [
("Object(X)[0]", "xxx"),
("new String(X)[0]", "xxx"),
("0 in Object(X)", "truetruetrue"),
("Object(X).hasOwnProperty(0)", "truetruetrue"),
("X.first()", "xxx"),
("Object(X)[524288]", "undefinedyz"),
] {
let src = format!("{build} '' + ({}) + ({}) + ({})", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u"));
let mut realm = Realm::with_options(Options::default(), limits.clone());
assert_eq!(realm.run(&src), Ok(JsValue::String(expected.into())), "{read}");
let interp = realm.interp();
interp.collect_garbage();
assert!(interp.heap.bytes <= limits.heap_bytes, "{read}: {} bytes live", interp.heap.bytes);
}
let wrapped =
Realm::with_options(Options::default(), Limits::default()).run(&format!("{build} Object(t)[524288] + new String(u)[524288] + t.first()"));
assert_eq!(wrapped, Ok(JsValue::String("yzx".into())));
}
#[test]
fn a_string_object_takes_fuel_for_the_rope_it_flattens() {
let limits = Limits { fuel: 40_000, ..Limits::default() };
let build = "var s = ' '; for (var i = 0; i < 20; i++) s = s + s; Object(s).length";
assert!(Realm::with_options(Options::default(), limits.clone()).run(build).is_ok());
for read in ["Object(s)[0]", "0 in new String(s)"] {
let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &format!("{build}; {read}"));
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{read}: {e:?}");
}
// A run that ends with an error right after the read leaves the next run its whole fuel.
let mut realm = Realm::with_options(Options::default(), Limits { fuel: 100_000, ..Limits::default() });
let defined = realm.run("var s = ' '; for (var i = 0; i < 20; i++) s = s + s; Object.defineProperty(new String(s), 0, { value: 'a' })");
assert!(matches!(defined, Err(ref e) if e.kind == RunErrorKind::Thrown), "{defined:?}");
assert!(realm.run("for (var j = 0; j < 5000; j++) {} j").is_ok());
}
#[test]
fn a_call_keeps_its_operands_while_a_string_receiver_is_boxed() {
// `g` leaves a garbage string, so the heap is close to its limit when the rope receiver of a
// non-strict function is boxed. The callee and the argument object are off the stack by
// then, and must survive anything boxing the receiver does.
let build = "var s = 'x'; for (var i = 0; i < 17; i++) s = s + s; function f(c) { return s + c; } \
function mk() { var a = f('a'); a[0]; return a + 'b'; } 0";
let mut realm = Realm::with_options(Options::default(), Limits::default());
assert_eq!(realm.run(build), Ok(JsValue::Number(0.0)));
let interp = realm.interp();
interp.collect_garbage();
interp.limits.heap_bytes = interp.heap.bytes + 2 * crate::heap::string_bytes(131_073) + (64 << 10);
interp.reset_gc_threshold();
let src = "(function () { var g = f('g'); g[0]; })(); (function (o) { return o.x; }).call(mk(), { x: 1 })";
assert_eq!(realm.run(src), Ok(JsValue::Number(1.0)));
}
#[test]
fn compiling_a_pattern_counts_against_the_heap() {
let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length";
+28
View File
@@ -163,6 +163,34 @@ impl Str {
flat
}
/// The code unit at `i` read along the rope without flattening it, visiting at most
/// `max_nodes` nodes: `None` when that is not enough, otherwise the unit (when `i` is in
/// range) and the number of nodes visited.
pub(crate) fn unit_at(&self, mut i: usize, max_nodes: u64) -> Option<(Option<u16>, u64)> {
let mut node = self.clone();
let mut visited = 0u64;
loop {
visited += 1;
if visited > max_nodes {
return None;
}
let next = match &*node.0.borrow() {
Node::Flat(f) => return Some((f.get(i).copied(), visited)),
Node::Concat { left, right, .. } => {
let left_len = left.len();
if i < left_len {
left.clone()
} else {
i -= left_len;
right.clone()
}
}
Node::Taken => return Some((None, visited)),
};
node = next;
}
}
pub(crate) fn to_rust_string(&self) -> String {
String::from_utf16_lossy(&self.flat())
}
+6 -3
View File
@@ -14,6 +14,7 @@ use crate::value::{Str, Value};
use crate::xml::{
Access, Content, NameRef, RESERVED_NS_BINDING, STAR, Xv, canonical_index, is_ncname, is_script_xml_name, ns_binding_allowed, xml_name_writable,
};
use crate::xpath::xpath;
const SETTINGS: [(&str, Value); 5] = [
("ignoreComments", Value::Bool(false)),
@@ -45,7 +46,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> {
method(interp, xml, "settings", settings, 0)?;
method(interp, xml, "setSettings", set_settings, 1)?;
method(interp, xml, "defaultSettings", default_settings, 0)?;
let xml_methods: [(&'static str, NativeFn, u32); 39] = [
let xml_methods: [(&'static str, NativeFn, u32); 40] = [
("addNamespace", add_namespace, 1),
("appendChild", append_child, 1),
("attribute", attribute, 1),
@@ -85,6 +86,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> {
("toString", to_string, 0),
("toXMLString", to_xml_string, 0),
("valueOf", value_of, 0),
("xpath", xpath, 1),
];
for (name, f, n) in xml_methods {
method(interp, xml_proto, name, f, n)?;
@@ -92,7 +94,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> {
let list = native(interp, "XMLList", xml_list_ctor, 1, NativeKind::Plain, true)?;
constructor(interp, "XMLList", list, list_proto)?;
let list_methods: [(&'static str, NativeFn, u32); 21] = [
let list_methods: [(&'static str, NativeFn, u32); 22] = [
("attribute", attribute, 1),
("attributes", attributes, 0),
("child", child, 1),
@@ -114,6 +116,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> {
("toString", to_string, 0),
("toXMLString", to_xml_string, 0),
("valueOf", value_of, 0),
("xpath", xpath, 1),
];
for (name, f, n) in list_methods {
method(interp, list_proto, name, f, n)?;
@@ -665,7 +668,7 @@ fn has_simple_content(interp: &mut Interp, this: Value, _: &[Value], _: bool) ->
/// The namespaces in scope at `x`, nearest declarations first, one for each prefix. Takes one unit
/// of fuel for each node it climbs through and for each declaration it reads.
fn scope_of(interp: &mut Interp, x: Option<ObjId>) -> JsResult<Vec<XmlNs>> {
pub(crate) fn scope_of(interp: &mut Interp, x: Option<ObjId>) -> JsResult<Vec<XmlNs>> {
let mut out: Vec<XmlNs> = Vec::new();
let mut seen: HashSet<Option<Key>> = HashSet::new();
let mut cur = x;
+466 -1
View File
@@ -372,7 +372,7 @@ fn the_xml_methods_behave_as_specified() {
assert_eq!(string("(<a>1</a>).toString()"), "1");
assert_eq!(string("(<a><b/></a>).toString()"), "<a>\n <b/>\n</a>");
truth("var x = <a/>; x.valueOf() === x");
truth("!Object.prototype.hasOwnProperty.call(XML.prototype, 'xpath') && !Object.prototype.hasOwnProperty.call(XMLList.prototype, 'xpath')");
truth("typeof XML.prototype.xpath === 'function' && typeof XMLList.prototype.xpath === 'function' && XML.prototype.xpath.length === 1");
truth("var x = <a><b/></a>; x.b.attributes !== undefined");
}
@@ -1279,6 +1279,471 @@ fn an_interrupt_stops_serialization() {
assert_eq!(r.run("stop(); x.toXMLString()").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted)));
}
// ---- xpath ----
/// A script function that joins the local names of a list's items.
const NAMES: &str = "function names(l) { var s = ''; for each (var n in l) s += n.localName(); return s; }";
#[test]
fn xpath_selects_child_attribute_and_text_nodes() {
for check in [
"xml.xpath('book').length() === 4",
"xml.xpath('book[2]/title') == 'Oz'",
"xml.xpath('book[last()]/title') == 'Opera'",
"xml.xpath('book[position() < 3]').length() === 2",
"xml.xpath('book[year > 1890]/title').length() === 3",
"xml.xpath('book[count(author) > 1]/title') == 'Alice'",
"xml.xpath('book[@category = \"CHILDREN\"]/title').length() === 2",
"xml.xpath('//author').length() === 6",
"xml.xpath('book/@category').length() === 4 && xml.xpath('book/@category')[0].nodeKind() === 'attribute'",
"xml.xpath('book[1]/title/text()')[0].nodeKind() === 'text' && xml.xpath('book[1]/title/text()') == 'Cookbook'",
"xml.xpath('book[1]/title/@lang') == 'en'",
"xml.xpath('nothing').length() === 0",
"xml.xpath('child::book[3]/child::author[2]') == 'B'",
"xml.xpath('*').length() === 4 && xml.xpath('book/node()').length() === 14",
] {
truth(&format!("{BOOKS} {check}"));
}
}
#[test]
fn xpath_returns_the_nodes_of_the_tree() {
truth(&format!("{BOOKS} xml.book[1].title[0].xpath('..')[0] === xml.book[1]"));
truth(&format!("{BOOKS} xml.xpath('book[2]')[0] === xml.book[1] && xml.xpath('.')[0] === xml"));
truth(&format!("{BOOKS} xml.xpath('book[1]/year')[0].setChildren('2000'); xml.book[0].year == '2000'"));
truth(&format!("{BOOKS} xml.xpath('book/@category')[1].setName('kind'); xml.book[1].@kind == 'CHILDREN'"));
truth("var x = <r><a>t</a></r>; x.a.text()[0].xpath('..')[0] === x.a[0]");
}
#[test]
fn xpath_absolute_paths_start_above_the_topmost_element() {
truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/bookstore/book').length() === 4"));
truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/*')[0] === xml"));
truth(&format!("{BOOKS} xml.book[0].title[0].xpath('//title').length() === 4"));
truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/title').length() === 0"));
truth("var d = <d><e/></d>; d.e[0].xpath('/d')[0] === d");
for src in ["(<r/>).xpath('/')", "(<r/>).xpath('..')", "(<r/>).xpath('ancestor-or-self::node()')"] {
assert_eq!(thrown_name(src), "TypeError", "{src}");
}
}
#[test]
fn xpath_walks_every_axis() {
let tree = "var x = <r><a><b/><c/></a><d><e/></d><f/></r>;";
for (expr, want) in [
("x.a[0].xpath('following-sibling::*')", "df"),
("x.f[0].xpath('preceding-sibling::*')", "ad"),
("x.f[0].xpath('preceding-sibling::*[1]')", "d"),
("x.d.e[0].xpath('ancestor::*')", "rd"),
("x.d.e[0].xpath('ancestor::*[1]')", "d"),
("x.d.e[0].xpath('ancestor-or-self::*')", "rde"),
("x.a.b[0].xpath('following::*')", "cdef"),
("x.d.e[0].xpath('preceding::*')", "abc"),
("x.d.e[0].xpath('preceding::*[1]')", "c"),
("x.xpath('descendant::*')", "abcdef"),
("x.xpath('descendant-or-self::*')", "rabcdef"),
("x.a[0].xpath('self::a')", "a"),
("x.a[0].xpath('self::b')", ""),
("x.a.b[0].xpath('parent::*')", "a"),
("x.xpath('//*[2]')", "cd"),
("x.xpath('(//*)[2]')", "a"),
("x.xpath('d/e/../preceding-sibling::*/c')", "c"),
] {
assert_eq!(string(&format!("{NAMES} {tree} names({expr})")), want, "{expr}");
}
let attr = "var x = <r><a p='1'><b/></a><c/></r>;";
assert_eq!(string(&format!("{NAMES} {attr} names(x.a.@p[0].xpath('following::*'))")), "bc");
assert_eq!(string(&format!("{NAMES} {attr} names(x.a.@p[0].xpath('ancestor::*'))")), "ra");
truth(&format!("{attr} x.a.@p[0].xpath('following-sibling::node()').length() === 0 && x.a.@p[0].xpath('preceding::*').length() === 0"));
}
#[test]
fn xpath_results_are_in_document_order_without_repeats() {
let tree = "var x = <r p='1'><a><b/></a><b/><c/></r>;";
for (expr, want) in [
("x.xpath('c | a | //b')", "abbc"),
("x.xpath('a | a')", "a"),
("x.xpath('//b/..')", "ra"),
("x.xpath('//*/ancestor::*')", "ra"),
("x.c[0].xpath('preceding::*[position() < 3]')", "bb"),
] {
assert_eq!(string(&format!("{NAMES} {tree} names({expr})")), want, "{expr}");
}
truth(&format!("{tree} x.xpath('c | @p')[0].nodeKind() === 'attribute' && x.xpath('c | @p').length() === 2"));
}
#[test]
fn xpath_names_match_by_namespace_uri() {
let tree = "var x = <r xmlns:p='urn:p'><p:a/><a/><q:a xmlns:q='urn:p'/><s:a xmlns:s='urn:s'/></r>;";
for check in [
"x.xpath('p:a').length() === 2",
"x.xpath('a').length() === 1 && x.xpath('a')[0].name().uri === ''",
"x.xpath('p:*').length() === 2",
"x.xpath('*').length() === 4",
"x.xpath('*[local-name() = \"a\"]').length() === 4",
"x.xpath('*[namespace-uri() = \"urn:s\"]').length() === 1",
"x.xpath('*[name() = \"p:a\"]').length() === 1",
] {
truth(&format!("{tree} {check}"));
}
truth("var x = <r xmlns='urn:d'><a/></r>; x.xpath('a').length() === 0 && x.xpath('*').length() === 1");
truth("var x = <r><a/></r>; default xml namespace = 'urn:d'; x.xpath('a').length() === 1");
truth("var x = <r><a xml:lang='en'/></r>; x.xpath('a/@xml:lang') == 'en'");
let rebound = "var x = <r xmlns:p='urn:1'><p:a/><b xmlns:p='urn:2'><p:a/></b></r>;";
truth(&format!("{rebound} x.xpath('//p:a').length() === 1 && x.xpath('//p:a')[0].parent() === x"));
truth(&format!("{rebound} x.b[0].xpath('p:a').length() === 1 && x.b[0].xpath('/r/p:a').length() === 0"));
assert_eq!(thrown_name("(<r/>).xpath('z:a')"), "TypeError");
}
#[test]
fn xpath_namespace_nodes_can_be_tested_but_not_returned() {
let tree = "var x = <r xmlns:p='urn:p'><a xmlns:q='urn:q'/></r>;";
truth(&format!("{tree} x.xpath('self::*[namespace::xml]').length() === 1"));
truth(&format!("{tree} x.xpath('a[count(namespace::*) = 3]').length() === 1"));
truth(&format!("{tree} x.xpath('a[namespace::q = \"urn:q\"]').length() === 1 && x.xpath('self::*[namespace::q]').length() === 0"));
truth(&format!("{tree} x.xpath('a[namespace::*[name() = \"p\"]/.. = .]').length() === 1"));
assert_eq!(thrown_name(&format!("{tree} x.xpath('namespace::*')")), "TypeError");
}
#[test]
fn xpath_variables_come_from_the_object_properties() {
truth(&format!("{BOOKS} xml.xpath('book[title = $t]/author', {{ t: 'Oz' }}) == 'Baum'"));
truth(&format!("{BOOKS} xml.xpath('book[$n]/title', {{ n: 3 }}) == 'Alice'"));
truth(&format!("{BOOKS} xml.xpath('book[$yes]', {{ yes: true }}).length() === 4 && xml.xpath('book[$no]', {{ no: false }}).length() === 0"));
truth(&format!("{BOOKS} xml.xpath('$b/title', {{ b: xml.book[1] }}) == 'Oz'"));
truth(&format!("{BOOKS} xml.xpath('$l/year', {{ l: xml.book.(@category == 'CHILDREN') }}).length() === 2"));
truth(&format!("{BOOKS} xml.xpath('book[year = $y]/title', {{ y: {{ toString: function () {{ return '1922'; }} }} }}) == 'Opera'"));
truth(&format!(
"{BOOKS} var o = {{}}; Object.defineProperty(o, 'v', {{ get: function () {{ return 'Alice'; }} }}); xml.xpath('book[title = $v]', o).length() === 1"
));
for src in
["(<r/>).xpath('$v')", "(<r/>).xpath('$v', {})", "(<r/>).xpath('$v', { v: undefined })", "(<r/>).xpath('a', 5)", "(<r/>).xpath('a', null)"]
{
assert_eq!(thrown_name(src), "TypeError", "{src}");
}
}
#[test]
fn xpath_keeps_the_items_of_a_list_variable_that_a_later_getter_empties() {
let setup = "var l = <><a><b/></a></>;";
let action = "var o = { get v() { return l; }, get w() { delete l[0]; l = null; squeeze(100000000); return 1; } };
var r = (<r/>).xpath('$v/b | $v[$w]', o); r.length() === 2 && r[0].localName() === 'a' && r[1].localName() === 'b'";
assert_eq!(after_setup(setup, action), Ok(JsValue::Bool(true)));
}
#[test]
fn xpath_functions_and_operators_follow_xpath_1() {
let tree = "var x = <r xml:lang='en-US'><a>1</a><a>2</a><b>x y</b><n>12.5</n></r>;";
let checks = [
"last() = 1 and position() = 1",
"count(a) = 2",
"count(id('a')) = 0",
"local-name() = 'r' and name(a) = 'a' and namespace-uri() = ''",
"local-name(nothing) = '' and name(@xml:lang) = 'xml:lang'",
"string(a) = '1' and string() = '12x y12.5' and string(nothing) = ''",
"concat('a', 'b', 'c') = 'abc'",
"starts-with('abc', 'ab') and not(starts-with('abc', 'b'))",
"contains('abc', 'bc') and contains('abc', '') and not(contains('abc', 'd'))",
"substring-before('1999/04/01', '/') = '1999' and substring-before('abc', 'd') = ''",
"substring-after('1999/04/01', '/') = '04/01' and substring-after('abc', '') = 'abc'",
"substring('12345', 2, 3) = '234' and substring('12345', 2) = '2345'",
"substring('12345', 1.5, 2.6) = '234' and substring('12345', 0, 3) = '12'",
"substring('12345', 0 div 0, 3) = '' and substring('12345', 1, 0 div 0) = ''",
"substring('12345', -42, 1 div 0) = '12345' and substring('12345', -1 div 0, 1 div 0) = ''",
"string-length('abc') = 3 and string-length(b) = 4 and string-length() = 10",
"normalize-space(b) = 'x y' and normalize-space(' x \t y ') = 'x y' and normalize-space(' ') = ''",
"translate('bar', 'abc', 'ABC') = 'BAr' and translate('--aaa--', 'abc-', 'ABC') = 'AAA'",
"boolean(a) and not(boolean(nothing)) and boolean('x') and not(boolean('')) and not(boolean(0)) and not(boolean(0 div 0))",
"true() and not(false())",
"lang('en') and lang('EN-us') and not(lang('fr')) and a[lang('en')]",
"number(n) = 12.5 and number(' -3 ') = -3 and string(number('1e3')) = 'NaN' and string(number('+1')) = 'NaN'",
"sum(a) = 3 and sum(nothing) = 0",
"floor(2.5) = 2 and ceiling(2.1) = 3 and floor(-2.5) = -3",
"round(2.5) = 3 and round(-2.5) = -2 and round(2.4) = 2 and string(round(-0.4)) = '0'",
"string(1 div 0) = 'Infinity' and string(-1 div 0) = '-Infinity' and string(0 div 0) = 'NaN'",
"string(1.5) = '1.5' and string(2) = '2' and string(-0.25) = '-0.25' and string(1 div 3) = '0.3333333333333333'",
"string(1000000 * 1000000 * 1000000 * 1000) = '1000000000000000000000'",
"7 mod 3 = 1 and -7 mod 3 = -1 and 5 div 2 = 2.5 and 2 - 3 = -1 and --1 = 1 and -(1 + 1) = -2",
"1 + 2 * 3 = 7 and (1 + 2) * 3 = 9 and 3 > 2 > 1 = false()",
"a = 2 and a = '1' and a != 1 and a > 1 and not(a > 2) and a < 2 and 1 < a and 2 >= a",
"a = a and a != a and not(a[1] != a[1]) and not(nothing = nothing) and not(nothing != a) and a < a",
"true() = a and false() = nothing and '1' = 1 and true() = 'x' and 1 = true() and not(0 = true())",
"a > false() and not(a > true()) and nothing < true() and not(nothing >= true())",
"1 < 2 and 2 > 1 or false()",
"false() and 1 div 0 or true()",
"'a' < 'b' = false() and '1' < '2'",
"*[2] = 2 and *[last()] = 12.5",
"(a | b)[2] = 2",
];
for check in checks {
let expr = format!("self::*[{check}]");
truth(&format!("{tree} x.xpath({expr:?}).length() === 1"));
}
}
#[test]
fn xpath_throws_a_type_error_for_a_value_that_is_not_a_node_set() {
for expr in ["count(book)", "string(book)", "1", "'a'", "true()", "book = 1", "-book"] {
let src = format!("{BOOKS} try {{ xml.xpath({expr:?}); 'no error' }} catch (e) {{ e.name + ': ' + e.message }}");
assert_eq!(string(&src), "TypeError: the XPath expression does not evaluate to a node-set", "{expr}");
}
}
#[test]
fn xpath_errors_name_their_cause() {
for expr in [
"",
" ",
"/[1]",
"book[",
"book]",
"(",
")",
"1 +",
"book/",
"@",
"foo::bar",
".[1]",
"..[1]",
"'abc",
"$",
"book book",
"!",
"a//",
",",
"count(",
"count(1,",
"a::",
"::a",
"a:",
"1..2",
"book[1]]",
"count(book))",
"//",
"/ /a",
"(1)(2)",
"processing-instruction(1)",
"node(1)",
"*:a",
"a:b:c",
] {
assert_eq!(thrown_name(&format!("{BOOKS} xml.xpath({expr:?})")), "SyntaxError", "{expr:?}");
}
for expr in
["foo()", "count()", "count(1)", "p:count(book)", "sum('1')", "1 | book", "'a'/b", "'a'[1]", "concat('a')", "name(1)", "substring('a')"]
{
assert_eq!(thrown_name(&format!("{BOOKS} xml.xpath({expr:?})")), "TypeError", "{expr:?}");
}
for src in ["(<r/>).xpath()", "(<r/>).xpath(5)", "(<r/>).xpath(new String('a'))", "XML.prototype.xpath.call({}, 'a')"] {
assert_eq!(thrown_name(src), "TypeError", "{src}");
}
}
#[test]
fn xpath_on_an_xml_list_runs_on_each_element_in_turn() {
truth(
"var l = <><a><b>1</b></a><a><b>2</b><b>3</b></a></>; l.xpath('b').length() === 3 && l.xpath('b').toXMLString() === '<b>1</b>\\n<b>2</b>\\n<b>3</b>'",
);
truth("var l = <><a><b>1</b></a><a><b>2</b><b>3</b></a></>; l.xpath('b[1]').length() === 2");
truth("var x = <r><a/><b/></r>; var l = x.*; var p = l.xpath('..'); p.length() === 2 && p[0] === x && p[1] === x");
truth("var x = <r><a>t</a></r>; var l = x.a.text() + x.a; l.xpath('.').length() === 1 && l.xpath('.')[0] === x.a[0]");
truth("var x = <r p='1'><!--c--><a/></r>; (x.@p + x.comments()).xpath('.').length() === 0");
truth("new XMLList().xpath('a').length() === 0 && new XMLList().xpath('z:a').length() === 0");
truth("var l = <><a xmlns:p='urn:1'><p:b/></a><a xmlns:p='urn:2'><p:b/></a></>; l.xpath('p:b').length() === 2");
assert_eq!(thrown_name("var l = <><a xmlns:p='urn:1'/><a/></>; l.xpath('p:b')"), "TypeError");
}
#[test]
fn xpath_expressions_of_any_depth_use_no_native_stack() {
let deep = format!("{}1{}", "(".repeat(100_000), ")".repeat(100_000));
assert_eq!(thrown_name(&format!("(<r/>).xpath('{deep}')")), "SyntaxError");
let predicates = format!("{}{}", "a[".repeat(100_000), "]".repeat(100_000));
assert_eq!(thrown_name(&format!("(<r/>).xpath('{predicates}')")), "SyntaxError");
let sum = format!("1{}", " + 1".repeat(100_000));
truth(&format!("(<r/>).xpath('self::*[{sum} = 100001]').length() === 1"));
let negated = format!("{}1", "-".repeat(100_001));
truth(&format!("(<r/>).xpath('self::*[{negated} = -1]').length() === 1"));
let union = format!("a{}", " | a".repeat(50_000));
truth(&format!("(<r><a/></r>).xpath('{union}').length() === 1"));
let mut shallow = Realm::with_options(Options { max_depth: 8, ..e4x() }, Limits::default());
let seven = format!("{}1{}", "(".repeat(6), ")".repeat(6));
assert_eq!(shallow.run(&format!("(<r/>).xpath('self::*[{seven}]').length()")), Ok(JsValue::Number(1.0)));
let eight = format!("{}1{}", "(".repeat(8), ")".repeat(8));
assert_eq!(shallow.run(&format!("try {{ (<r/>).xpath('{eight}') }} catch (e) {{ e.message }}")), Ok(JsValue::String("nesting too deep".into())));
}
#[test]
fn xpath_stops_at_the_fuel_and_heap_limits_leaving_the_tree() {
let setup = "var x = <r/>; for (var i = 0; i < 6; i++) x.appendChild(<a><b>{i}</b></a>);";
let check = "x.a.length() === 6 && x.a[5].b == '5' && x.*.length() === 6";
assert!(fuel_stops(setup, "x.xpath('//b[. > 2]/preceding::*')", check) > 0);
assert!(heap_stops(setup, "x.xpath((SPARE, '//b[contains(concat(., ., .), \"3\")] | //a'))", check) > 0);
let mut r = realm();
r.run("var x = <r/>; for (var i = 0; i < 500; i++) x.appendChild(<a/>);").unwrap();
let interrupt = r.interrupt();
r.define_function("stop", move |_| {
interrupt.set();
Ok(JsValue::Undefined)
});
assert_eq!(r.run("stop(); x.xpath('//a/following::a')").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted)));
}
#[test]
fn xpath_walks_of_deep_trees_and_long_lists_count_against_the_heap_limit() {
let deep = "var s = ''; for (var i = 0; i < 5000; i++) s += '<a>'; s += '<b/>'; for (var i = 0; i < 5000; i++) s += '</a>'; var x = new XML(s);";
for (action, room) in [
("x.xpath('descendant::c').length()", JsValue::Number(0.0)),
("x.xpath('self::*[string() = \"q\"]').length()", JsValue::Number(0.0)),
("x.xpath('descendant::b | self::a').length()", JsValue::Number(2.0)),
] {
for (spare, want) in [(20_000, Err(RunErrorKind::Limit(LimitKind::Heap))), (50_000_000, Ok(room.clone()))] {
let mut r = Realm::with_options(Options { max_depth: 1_000_000, ..e4x() }, Limits::default());
let interp = r.interp();
let global = interp.global;
assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok());
r.run(deep).unwrap();
assert_eq!(r.run(&format!("squeeze({spare}); {action}")).map_err(|e| e.kind), want, "{action} {spare}");
}
}
let long = "var s = '<r>'; for (var i = 0; i < 20000; i++) s += '<a/>'; var l = new XML(s + '</r>').a;";
assert_eq!(after_setup(long, "squeeze(50000); l.xpath('self::c').length()"), Err(RunErrorKind::Limit(LimitKind::Heap)));
assert_eq!(after_setup(long, "squeeze(50000000); l.xpath('self::c').length()"), Ok(JsValue::Number(0.0)));
}
#[test]
fn xpath_reading_a_long_string_takes_fuel_for_its_length() {
let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var x = <r/>; for (var i = 0; i < 200; i++) x.appendChild(<a>y</a>);";
for expr in ["a[string-length($s) = 1]", "a[starts-with($s, $s) = false()]", "a[$s = concat('', 'x')]", "a[number($s) = 1]", "a[. = $s]"] {
let mut r = realm();
r.run(setup).unwrap();
let src = format!("x.xpath({expr:?}, {{ s: s }}).length()");
assert_eq!(r.run(&src), Ok(JsValue::Number(0.0)), "{expr}");
r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() });
assert_eq!(r.run(&src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)), "{expr}");
}
}
#[test]
fn xpath_string_functions_hold_the_bytes_they_build() {
let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var d = '1'; for (var i = 0; i < 20; i++) d += d; var o = { s: s, d: d };";
for (expr, spare) in [("translate('a', 'b', $s) = 'a'", 1_000_000), ("number($d) > 1", 500_000), ("substring($s, 1) != 'z'", 4_000_000)] {
let action = |spare: u32| format!("squeeze({spare}); (<r/>).xpath(\"self::*[{expr}]\", o).length()");
assert_eq!(after_setup(setup, &action(spare)), Err(RunErrorKind::Limit(LimitKind::Heap)), "{expr}");
assert_eq!(after_setup(setup, &action(100_000_000)), Ok(JsValue::Number(1.0)), "{expr}");
}
}
#[test]
fn xpath_string_variables_hold_the_bytes_of_their_flat_copy() {
// The setup ends with `0` so that its completion value does not flatten `s`.
let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var o = { s: s }; 0;";
let action = |spare: u32| format!("squeeze({spare}); (<r/>).xpath('self::*[$s]', o).length()");
assert_eq!(after_setup(setup, &action(1_000_000)), Err(RunErrorKind::Limit(LimitKind::Heap)));
assert_eq!(after_setup(setup, &action(100_000_000)), Ok(JsValue::Number(1.0)));
}
#[test]
fn xpath_substring_searches_take_fuel_for_every_position_tried() {
let setup = "var h = 'a'; for (var i = 0; i < 16; i++) h += h; var n = h.substring(0, 4095) + 'b'; var o = { h: h, n: n };";
for expr in ["contains($h, $n)", "substring-before($h, $n) = ''", "substring-after($h, $n) = ''"] {
let mut r = realm();
r.run(setup).unwrap();
let src = format!("(<r/>).xpath(\"self::*[{expr}]\", o).length()");
let want = if expr.starts_with("contains") { 0.0 } else { 1.0 };
assert_eq!(r.run(&src), Ok(JsValue::Number(want)), "{expr}");
r.set_limits(Limits { fuel: 5_000_000, ..Limits::default() });
assert_eq!(r.run(&src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)), "{expr}");
}
}
#[test]
fn xpath_lang_takes_fuel_for_every_attribute_it_reads() {
let setup = "var s = '<r'; for (var i = 0; i < 10000; i++) s += ' a' + i + '=\"\"'; s += '>'; \
for (var i = 0; i < 400; i++) s += '<a/>'; var x = new XML(s + '</r>');";
let mut r = realm();
r.run(setup).unwrap();
let src = "x.xpath('a[lang(\"en\")]').length()";
assert_eq!(r.run(src), Ok(JsValue::Number(0.0)));
r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() });
assert_eq!(r.run(src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)));
}
#[test]
fn xpath_on_a_large_tree_ends_within_the_default_limits() {
let setup = "var x = <r/>; for (var i = 0; i < 3000; i++) x.appendChild(<a n={i}><b/></a>);";
truth(&format!("{setup} x.xpath('//a[@n = 2999]/b').length() === 1 && x.xpath('//b/..').length() === 3000"));
let mut r = realm();
r.run("var x = <r/>; for (var i = 0; i < 2000; i++) x.appendChild(<a/>);").unwrap();
r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() });
assert_eq!(r.run("x.xpath('//a/following::a')").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)));
}
const XPATH_PIECES: &[&str] = &[
"/",
"//",
"a",
"b",
"*",
"@",
"@*",
".",
"..",
"[",
"]",
"(",
")",
"1",
"'x'",
"$v",
"|",
"+",
"-",
"=",
"!=",
"<",
">=",
" and ",
" or ",
" div ",
" mod ",
"child::",
"following::",
"preceding::",
"ancestor::",
"namespace::",
"node()",
"text()",
"count(",
"last()",
"position()",
"concat(",
"substring(",
",",
"p:",
"p:*",
"xml:lang",
"::",
":",
"'",
" ",
];
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn random_xpath_expressions_select_nodes_or_throw(parts in proptest::collection::vec(0..XPATH_PIECES.len(), 0..30)) {
let expr: String = parts.iter().map(|&i| XPATH_PIECES[i]).collect();
let quoted = format!("{expr:?}");
let src = format!("var x = <r xmlns:p='urn:p' xml:lang='en'><a p:q='1'><b>2</b><!--c--></a><p:b>3</p:b>t</r>; typeof XML.prototype.xpath === 'function' && x.a[0].xpath({quoted}, {{ v: 1 }}).length() >= 0");
let mut r = limited(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 });
match r.run(&src) {
Ok(v) => prop_assert_eq!(v, JsValue::Bool(true), "{}", expr),
Err(e) => prop_assert!(matches!(e.kind, RunErrorKind::Thrown | RunErrorKind::Limit(_)), "{expr:?}: {e:?}"),
}
}
}
// ---- generated input ----
const PIECES: &[&str] = &[
File diff suppressed because it is too large Load Diff
+19 -1
View File
@@ -634,6 +634,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state",
),
(
"window.learn",
"Learn",
"",
"{show?: bool} show (true), hide (false) or toggle (omitted or null) the Learn panel, as Window › Learn and the app bar's Explore tutorials button do; any other value is an error and changes nothing; returns whether the panel shows",
),
(
"learn.list",
"List Tutorials",
"",
"{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}",
),
(
"learn.show",
"Show Tutorial",
"",
"{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}",
),
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
(
"window.workspace",
@@ -1492,7 +1510,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"css.exportFile",
"Export CSS…",
"",
"{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}",
"{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists up to 30 of them, fewer when the window is small, with how many were left out)}",
),
(
"file.print",
+79 -25
View File
@@ -88,12 +88,25 @@ pub(crate) struct Scan {
/// Scan `src` (already through [`unescaped`]): the script itself (`file` none) or a file it includes
/// (`file`: its path inside the script's folder), with the names it declares (`declared`, from
/// [`declarations`] on the same `src`), the `#includepath` folders `paths` in force at its start and
/// its variables holding what `known` says.
pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str>, paths: Rc<[String]>, known: &Known, acc: &mut Acc) -> Scan {
/// its variables holding what `known` says. Each of the `\u{…}` escapes `braced` that [`unescaped`]
/// read is syntax later than ECMAScript 3, reported when the scan reaches the escape's line.
pub(crate) fn scan<'s>(
src: &'s str,
declared: &Declared<'s>,
braced: &[Braced],
file: Option<&str>,
paths: Rc<[String]>,
known: &Known,
acc: &mut Acc,
) -> Scan {
let mut lexer = Lexer::new(src, acc.limits.nesting);
let mut a = Analyzer::new(acc, file, paths, known, declared);
let mut breaks = LineBreaks::default();
let mut braced = braced.iter().peekable();
for t in lexer.by_ref() {
while let Some((line, escape)) = braced.next_if(|(line, _)| *line <= t.line) {
a.acc.problem(report::NEWER_SYNTAX, Some(*line), file, Some(escape));
}
if let Some(code) = t.problem {
a.problem(code, t.line);
}
@@ -103,6 +116,9 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str>
Read::Token(sig) => a.token(sig),
}
}
for (line, escape) in braced {
a.acc.problem(report::NEWER_SYNTAX, Some(*line), file, Some(escape));
}
if let Some(line) = lexer.unclosed_xml {
a.problem(report::UNTERMINATED_XML, line);
}
@@ -115,29 +131,61 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str>
Scan { includes, module, lines: lexer.lines(), known }
}
/// `src` with the `\uXXXX` escapes in its names written as the characters they stand for (ECMA-262
/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character a
/// name may not hold where the escape stands stays as written. Line breaks don't change, so every
/// line keeps its number.
pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> Cow<'a, str> {
match unescape_names(&src, acc) {
Some(out) => Cow::Owned(out),
None => src,
/// A `\u{…}` escape in a name, which ECMAScript 3 does not have: its line and its text.
pub(crate) type Braced = (usize, String);
/// `src` with the `\uXXXX` and `\u{…}` escapes in its names and private names written as the
/// characters they stand for (ECMA-262, Names and Keywords), and `src` itself when no name has one,
/// with the `\u{…}` escapes read, each listed once per line in a row. An escape for a character a
/// name may not hold where the escape stands stays as written, and so does a private name that
/// starts a line where the name it spells would start a preprocessor line. Line breaks don't
/// change, so every line keeps its number.
pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> (Cow<'a, str>, Vec<Braced>) {
let mut braced = vec![];
match unescape_names(&src, acc, &mut braced) {
Some(out) => (Cow::Owned(out), braced),
None => (src, braced),
}
}
fn unescape_names(src: &str, acc: &mut Acc) -> Option<String> {
fn unescape_names(src: &str, acc: &mut Acc, braced: &mut Vec<Braced>) -> Option<String> {
if !src.contains("\\u") {
return None;
}
let mut out = String::with_capacity(src.len());
let mut changed = false;
let mut lexer = Lexer::new(src, acc.limits.nesting);
for t in lexer.by_ref() {
if t.kind == T::Ident && t.text.contains("\\u") {
let name = unescape_name(t.text);
changed |= name != t.text;
out.push_str(&name);
// One more than the report keeps, so that the report is marked truncated.
let room = acc.limits.entries.saturating_add(1);
// Only blanks since the last line break (or the start).
let mut line_start = true;
let mut found = vec![];
while let Some(t) = lexer.next() {
// A private name is `#` and a name.
let (mark, name) = match t.kind {
T::Ident => ("", t.text),
T::Other => t.text.strip_prefix('#').map_or(("", ""), |name| ("#", name)),
_ => ("", ""),
};
let starts_line = line_start;
line_start = t.kind == T::Newline || (line_start && t.kind == T::Space);
if name.contains("\\u") {
found.clear();
let written = unescape_name(name, t.line, &mut found, room);
for escape in found.drain(..) {
if braced.last() != Some(&escape) && braced.len() < room {
braced.push(escape);
}
}
// A private name that starts a line stays as written where the name it spells would
// start a preprocessor line.
if mark == "#" && starts_line && !lexer.private_line(&written) {
out.push_str(t.text);
continue;
}
changed |= written != name;
out.push_str(mark);
out.push_str(&written);
} else {
out.push_str(t.text);
}
@@ -146,25 +194,31 @@ fn unescape_names(src: &str, acc: &mut Acc) -> Option<String> {
changed.then_some(out)
}
/// The name `text` with each `\uXXXX` escape for a character a name may hold where it stands written
/// as that character: a character a name may start with first, and one a name may hold after that.
fn unescape_name(text: &str) -> String {
/// The name `text`, on `line`, with each escape for a character a name may hold where it stands
/// written as that character: a character a name may start with first, and one a name may hold
/// after that. Each `\u{…}` escape read goes into `braced`, unless the last one there is the same
/// escape on the same line or `braced` holds `room` of them.
fn unescape_name(text: &str, line: usize, braced: &mut Vec<Braced>, room: usize) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(i) = rest.find("\\u") {
out.push_str(rest.get(..i).unwrap_or_default());
let after = rest.get(i + 2..).unwrap_or_default();
let at = rest.get(i..).unwrap_or_default();
let first = out.is_empty();
let c = crate::lex::escaped(rest.get(i..).unwrap_or_default())
.filter(|&c| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) });
let c = crate::lex::escape(at).filter(|&(c, _)| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) });
match c {
Some(c) => {
Some((c, len)) => {
out.push(c);
rest = after.get(4..).unwrap_or_default();
let source = at.get(..len).unwrap_or_default();
let repeated = braced.last().is_some_and(|(l, s)| *l == line && s == source);
if source.starts_with("\\u{") && !repeated && braced.len() < room {
braced.push((line, source.to_string()));
}
rest = at.get(len..).unwrap_or_default();
}
None => {
out.push_str("\\u");
rest = after;
rest = at.get(2..).unwrap_or_default();
}
}
}
+2 -2
View File
@@ -141,9 +141,9 @@ pub(crate) fn follow(
read.insert(path.clone(), (status, Some(file)));
let (text, _) = crate::decode(&b);
let mut tallies = Acc::new(limits);
let text = analyze::unescaped(text, &mut tallies);
let (text, _) = analyze::unescaped(text, &mut tallies);
let declared = analyze::declarations(&text, &mut tallies);
let scan = analyze::scan(&text, &declared, Some(&path), Rc::clone(&inc.paths), known, &mut tallies);
let scan = analyze::scan(&text, &declared, &[], Some(&path), Rc::clone(&inc.paths), known, &mut tallies);
acc.steps = acc.steps.saturating_add(tallies.steps);
known.add(scan.known, limits.entries);
checked.push(ReadFile { path: path.clone(), bytes: b, paths: Rc::clone(&inc.paths) });
+84 -15
View File
@@ -41,7 +41,9 @@ pub(crate) enum T {
/// end or the next `{`.
XmlMore,
Punct,
/// A character no token starts with, or `#` and a name away from the start of a line.
/// A character no token starts with, or a private name: `#` and a name away from the start of a
/// line, or at its start where the name starts with an escape, or inside braces where the name
/// is not a directive's.
Other,
}
@@ -166,14 +168,33 @@ pub(crate) fn ident_part(c: char) -> bool {
}
}
/// The character the `\uXXXX` escape at the start of `s` stands for: `None` when `s` doesn't start
/// with `\u` and four hexadecimal digits, or when they give a lone surrogate.
pub(crate) fn escaped(s: &str) -> Option<char> {
s.strip_prefix("\\u")?
.get(..4)
.filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit()))
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
.and_then(char::from_u32)
/// The character the escape at the start of `s` stands for and the escape's length in bytes: `\u`
/// and four hexadecimal digits, or `\u{…}` with one or more hexadecimal digits of a value up to
/// U+10FFFF, leading zeros allowed (ECMA-262, UnicodeEscapeSequence and CodePoint). `None` when `s`
/// starts with neither, or when the escape gives a lone surrogate.
pub(crate) fn escape(s: &str) -> Option<(char, usize)> {
let rest = s.strip_prefix("\\u")?;
let Some(braced) = rest.strip_prefix('{') else {
let c = rest
.get(..4)
.filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit()))
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
.and_then(char::from_u32)?;
return Some((c, 6));
};
// The value stops growing past U+10FFFF, so any number of digits is read once and never
// overflows.
let mut value: u32 = 0;
for (i, b) in braced.bytes().enumerate() {
match b {
b'}' if i > 0 => return char::from_u32(value).map(|c| (c, i + 4)),
_ => {
let digit = char::from(b).to_digit(16)?;
value = (value * 16 + digit).min(0x11_0000);
}
}
}
None
}
/// `Other_ID_Start` (Unicode PropList 17.0): characters kept in `ID_Start` though their category is
@@ -394,18 +415,66 @@ impl<'s> Lexer<'s> {
}
/// The rest of a name (its first character included): the characters [`ident_part`] allows,
/// written out or as `\uXXXX` escapes. An escape for any other character, or one without four
/// hexadecimal digits, ends the name.
/// written out or as `\uXXXX` or `\u{…}` escapes ([`escape`]). An escape for any other
/// character, or a malformed one, ends the name. Each character of an escape is a step.
fn name(&mut self) {
loop {
match self.peek() {
Some('\\') if escaped(self.rest()).is_some_and(ident_part) => self.skip(6),
Some('\\') => match escape(self.rest()) {
Some((c, len)) if ident_part(c) => {
self.pos += len;
self.steps += len;
}
_ => break,
},
Some(c) if ident_part(c) => self.bump(c),
_ => break,
}
}
}
/// A name starts right after the `#` here: its first character written out, or an escape for a
/// character a name may start with.
fn private_name_follows(&self) -> bool {
let after = self.rest().get(1..).unwrap_or_default();
match after.chars().next() {
Some('\\') => escape(after).is_some_and(|(c, _)| ident_start(c)),
Some(c) => ident_start(c),
None => false,
}
}
/// The `#` at the start of this line starts a private name, not a preprocessor line: the name
/// starts with an escape, which no directive's name holds, or [`Lexer::private_line`] holds for
/// it.
fn private_at_line_start(&self) -> bool {
if !self.private_name_follows() {
return false;
}
let after = self.rest().get(1..).unwrap_or_default();
after.starts_with('\\') || self.private_line(after)
}
/// A line that starts with `#` and then `rest` is a private name here, not a preprocessor line:
/// the line is inside braces or in the code of an XML literal, and the name `rest` starts with
/// is not a directive's. The name runs over every character a name may hold, written out or as
/// an escape, so `#include$size` is not `#include`.
pub fn private_line(&self, rest: &str) -> bool {
let in_code = !self.braces.is_empty() || self.deeper_braces > 0 || !self.xml.is_empty();
let mut end = 0;
while let Some(more) = rest.get(end..) {
let len = match more.chars().next() {
Some('\\') => escape(more).filter(|&(c, _)| ident_part(c)).map(|(_, len)| len),
Some(c) if ident_part(c) => Some(c.len_utf8()),
_ => None,
};
let Some(len) = len else { break };
end += len;
}
let name = rest.get(..end).unwrap_or_default();
in_code && !catalog::DIRECTIVES.iter().any(|d| d.eq_ignore_ascii_case(name))
}
fn digits(&mut self, ok: fn(char) -> bool) {
while let Some(c) = self.peek().filter(|&c| ok(c) || c == '_') {
self.bump(c);
@@ -871,10 +940,10 @@ impl<'s> Iterator for Lexer<'s> {
} else if self.starts("/*") {
problem = self.block_comment();
T::Comment
} else if c == '#' && self.line_start {
} else if c == '#' && self.line_start && !self.private_at_line_start() {
self.until_line_break();
T::Directive
} else if c == '#' && self.peek_at(1).is_some_and(ident_start) {
} else if c == '#' && self.private_name_follows() {
self.bump('#');
self.name();
T::Other
@@ -906,7 +975,7 @@ impl<'s> Iterator for Lexer<'s> {
self.close_level();
self.bump('}');
return Some(self.markup_token(start, line, T::XmlMore, stmt, expr));
} else if ident_start(c) || (c == '\\' && escaped(self.rest()).is_some_and(ident_start)) {
} else if ident_start(c) || (c == '\\' && escape(self.rest()).is_some_and(|(c, _)| ident_start(c))) {
self.name();
T::Ident
} else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) {
+20 -13
View File
@@ -166,14 +166,14 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits:
if invalid {
acc.problem(report::NOT_UTF8, None, None, None);
}
let text = analyze::unescaped(text, &mut acc);
let (text, braced) = analyze::unescaped(text, &mut acc);
let declared = analyze::declarations(&text, &mut acc);
let none = analyze::Known::default();
// With a loader, this first scan finds the includes and what the script's variables hold; its
// tallies are left out.
let mut tallies = report::Acc::new(limits);
let pre = acc.clone();
let scan = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc });
let scan = analyze::scan(&text, &declared, &braced, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc });
acc.steps = acc.steps.saturating_add(tallies.steps);
if scan.module {
// A module's report holds its kind and its lines, and none of what the scan found.
@@ -183,7 +183,7 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits:
return acc.finish(Kind::Module, scan.lines);
}
let Some(loader) = loader else {
let mut acc = settle(acc, pre, &text, &declared, scan.known);
let mut acc = settle(acc, pre, &text, &declared, &braced, scan.known);
includes::not_read(&mut acc, scan.includes);
return acc.finish(Kind::Script, scan.lines);
};
@@ -191,17 +191,23 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits:
let read = includes::follow(&mut acc, scan.includes, loader, name, bytes.len(), &mut known);
// Each file's text and the names it declares are worked out once for all the scans below.
let decoded: Vec<_> = read.iter().map(|file| decode(&file.bytes)).collect();
let texts: Vec<_> = decoded.into_iter().map(|(text, invalid)| (analyze::unescaped(text, &mut acc), invalid)).collect();
let declarations: Vec<_> = texts.iter().map(|(text, _)| analyze::declarations(text, &mut acc)).collect();
let texts: Vec<_> = decoded
.into_iter()
.map(|(text, invalid)| {
let (text, braced) = analyze::unescaped(text, &mut acc);
(text, braced, invalid)
})
.collect();
let declarations: Vec<_> = texts.iter().map(|(text, ..)| analyze::declarations(text, &mut acc)).collect();
// A variable may get what it holds from another file's variable: the files are scanned again,
// each with what is known so far, while that grows, at most three more times.
for _ in 0..3 {
let before = known.len();
let mut tallies = report::Acc::new(limits);
let more = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &known, &mut tallies).known;
let more = analyze::scan(&text, &declared, &[], None, std::rc::Rc::from([]), &known, &mut tallies).known;
known.add(more, limits.entries);
for ((file, (text, _)), declared) in read.iter().zip(&texts).zip(&declarations) {
let more = analyze::scan(text, declared, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known;
for ((file, (text, ..)), declared) in read.iter().zip(&texts).zip(&declarations) {
let more = analyze::scan(text, declared, &[], Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known;
known.add(more, limits.entries);
}
acc.steps = acc.steps.saturating_add(tallies.steps);
@@ -210,12 +216,12 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits:
}
}
// The scans for the report: every file starts with what any file's variables hold.
let lines = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &known, &mut acc).lines;
for ((file, (text, invalid)), declared) in read.iter().zip(&texts).zip(&declarations) {
let lines = analyze::scan(&text, &declared, &braced, None, std::rc::Rc::from([]), &known, &mut acc).lines;
for ((file, (text, braced, invalid)), declared) in read.iter().zip(&texts).zip(&declarations) {
if *invalid {
acc.problem(report::NOT_UTF8, None, Some(&file.path), None);
}
analyze::scan(text, declared, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut acc);
analyze::scan(text, declared, braced, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut acc);
}
acc.finish(Kind::Script, lines)
}
@@ -230,6 +236,7 @@ fn settle<'l>(
pre: report::Acc<'l>,
text: &str,
declared: &analyze::Declared<'_>,
braced: &[analyze::Braced],
mut known: analyze::Known,
) -> report::Acc<'l> {
if known.len() == 0 {
@@ -240,7 +247,7 @@ fn settle<'l>(
for _ in 0..3 {
let before = known.len();
let mut acc = pre.clone();
let more = analyze::scan(text, declared, None, std::rc::Rc::from([]), &known, &mut acc).known;
let more = analyze::scan(text, declared, braced, None, std::rc::Rc::from([]), &known, &mut acc).known;
known.add(more, entries);
if known.len() == before {
acc.steps = acc.steps.saturating_add(spent);
@@ -249,7 +256,7 @@ fn settle<'l>(
spent = spent.saturating_add(acc.steps.saturating_sub(pre.steps));
}
let mut acc = pre;
analyze::scan(text, declared, None, std::rc::Rc::from([]), &known, &mut acc);
analyze::scan(text, declared, braced, None, std::rc::Rc::from([]), &known, &mut acc);
acc.steps = acc.steps.saturating_add(spent);
acc
}
+151 -2
View File
@@ -271,7 +271,7 @@ fn names_written_with_escapes_are_read_as_the_names_they_spell() {
/// `src` with the escapes in its names written as the characters they stand for.
fn unescaped(src: &str) -> String {
let limits = Limits::default();
crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).into_owned()
crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).0.into_owned()
}
#[test]
@@ -364,6 +364,155 @@ fn malformed_escapes_in_bulk_are_read_once_through() {
assert_eq!(r.lines, 3);
assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[3][..]));
assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len());
// Braced escapes: long runs of digits, closed and not, and ones that end at once.
let zeros = "0".repeat(100_000);
let src = format!(
"a\\u{{{zeros}62}};\n\\u{{{}\n{}\n{}\nalert(1);\n",
"f".repeat(100_000),
"\\u{".repeat(20_000),
"\\u{0000000061}\\u{110000}\\u{}\\u{D800}#\\u{61}".repeat(10_000)
);
let r = report(&src);
assert_eq!(r.lines, 5);
assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[5][..]));
assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len());
}
#[test]
fn braced_escapes_in_names_are_read_by_their_value() {
for (src, want) in [
("\\u{61}pp", "app"),
("a\\u{62}c", "abc"),
("\\u{0000000061}", "a"),
("\\u{2118}\\u{203F}", "\u{2118}\u{203F}"),
("a\\u{1D7CE}", "a\u{1D7CE}"),
("\\u{1D49C}", "\u{1D49C}"),
// Not at the start of a name, in no name, out of range, empty, a surrogate and not closed.
("\\u{30}a", "\\u{30}a"),
("a\\u{1F600}", "a\\u{1F600}"),
("a\\u{110000}", "a\\u{110000}"),
("a\\u{FFFFFFFFFFFFFFFFFFFF}b", "a\\u{FFFFFFFFFFFFFFFFFFFF}b"),
("a\\u{}b", "a\\u{}b"),
("a\\u{D800}", "a\\u{D800}"),
("a\\u{61", "a\\u{61"),
("a\\u{6 1}", "a\\u{6 1}"),
] {
assert_eq!(unescaped(src), want, "{src:?}");
}
assert_eq!(tokens("\\u{61}pp.x"), [(T::Ident, "\\u{61}pp"), (T::Punct, "."), (T::Ident, "x")]);
assert_eq!(tokens("a\\u{0000000062}"), [(T::Ident, "a\\u{0000000062}")]);
assert_eq!(tokens("a\\u{1F600}").get(..2), Some(&[(T::Ident, "a"), (T::Other, "\\")][..]));
}
#[test]
fn braced_name_escape_reports_later_syntax_and_member() {
let r = report("\\u{61}pp.activeDocument.acmeName;\nvar a\\u{62}c = 1, \\u{0000000061}pp2 = 2;\n\\u{61}pp.x + \\u{61}pp.y;\n");
let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect();
assert_eq!(members, ["app.activeDocument.acmeName", "app.x", "app.y"]);
assert_eq!(
problems(&r),
[
("newerSyntax", Some(1), Some("\\u{61}")),
("newerSyntax", Some(2), Some("\\u{62}")),
("newerSyntax", Some(2), Some("\\u{0000000061}")),
("newerSyntax", Some(3), Some("\\u{61}")),
]
);
// An escape that isn't a name's character, or isn't closed, makes no name and no report of one.
for src in [
"\\u{1F600}pp.activeDocument;",
"\\u{110000}pp.activeDocument;",
"\\u{}pp.activeDocument;",
"\\u{D800}pp.activeDocument;",
"\\u{61pp.activeDocument;",
] {
let r = report(src);
assert!(r.members.iter().all(|m| !m.name.starts_with("app")), "{src:?}: {:?}", r.members);
assert!(r.problems.iter().all(|p| p.detail.as_deref().is_none_or(|d| !d.starts_with("\\u{"))), "{src:?}: {:?}", r.problems);
}
// In an included file, the report names the file.
let mut m = mem(&[("lib.jsxinc", "\\u{61}pp.name;\n")]);
let r = check("main.jsx", b"#include 'lib.jsxinc'\n", Some(&mut m), &Limits::default());
let found: Vec<_> = r.problems.iter().map(|p| (p.code, p.line, p.file.as_deref(), p.detail.as_deref())).collect();
assert_eq!(found, [("newerSyntax", Some(1), Some("lib.jsxinc"), Some("\\u{61}"))]);
}
#[test]
fn private_names_with_escaped_starts_are_single_tokens() {
assert_eq!(tokens("a #\\u0061b"), [(T::Ident, "a"), (T::Other, "#\\u0061b")]);
assert_eq!(tokens("this.#\\u0061pp"), [(T::Ident, "this"), (T::Punct, "."), (T::Other, "#\\u0061pp")]);
assert_eq!(tokens("o.#\\u{61}b"), [(T::Ident, "o"), (T::Punct, "."), (T::Other, "#\\u{61}b")]);
// An escape for a character no name starts with leaves `#` on its own.
assert_eq!(tokens("a #\\u0030b").get(..3), Some(&[(T::Ident, "a"), (T::Other, "#"), (T::Other, "\\")][..]));
let r = report("var q = this.#\\u0061pp;\nvar r = o.#\\u{61}b;\n");
assert!(r.members.is_empty(), "{:?}", r.members);
assert_eq!(
problems(&r),
[("newerSyntax", Some(1), Some("#app")), ("newerSyntax", Some(2), Some("\\u{61}")), ("newerSyntax", Some(2), Some("#ab"))]
);
}
#[test]
fn private_name_reports_decoded_construct() {
let r = report("var q = this.#hid\\u0064en;\nvar r = this.#\\u0061\\u0062;\n");
assert_eq!(problems(&r), [("newerSyntax", Some(1), Some("#hidden")), ("newerSyntax", Some(2), Some("#ab"))]);
}
#[test]
fn private_name_at_line_start_is_not_a_directive() {
let r = report(concat!(
"class Box {\n",
" #size = 1;\n",
" #\\u0061rea = 2;\n",
" #include 'a.jsxinc'\n",
" #\\u0069nclude = 4;\n",
" #\\u{69}nclude = 5;\n",
"}\n",
"#\\u0062ox = 3;\n",
"#pragma once\n",
"#target acme\n",
));
let directives: Vec<_> = r.directives.iter().map(|d| (d.name, d.line)).collect();
assert_eq!(directives, [("include", 4), ("target", 10)]);
// Where the name a private name's escapes spell would start a preprocessor line, the report
// gives the name as written.
assert_eq!(
problems(&r),
[
("newerSyntax", Some(1), Some("class")),
("newerSyntax", Some(2), Some("#size")),
("newerSyntax", Some(3), Some("#area")),
("newerSyntax", Some(5), Some("#\\u0069nclude")),
("newerSyntax", Some(6), Some("\\u{69}")),
("newerSyntax", Some(6), Some("#\\u{69}nclude")),
("newerSyntax", Some(8), Some("#\\u0062ox")),
("unknownDirective", Some(9), Some("#pragma")),
]
);
}
#[test]
fn private_name_that_starts_with_a_directive_name_is_not_a_directive() {
let r = report(concat!(
"class Box {\n",
" #include$size = 1;\n",
" #include\u{e9} = 2;\n",
" #include\\u0041 = 3;\n",
" #include\\u{41} = 4;\n",
" #INCLUDE 'a.jsxinc'\n",
"}\n",
));
let directives: Vec<_> = r.directives.iter().map(|d| (d.name, d.line)).collect();
assert_eq!(directives, [("include", 6)]);
}
#[test]
fn xml_code_uses_escaped_names() {
let r = report("var x = <a>{\\u{61}pp.activeDocument}</a>;\nvar y = <b>\\u0061pp.z \\u{61}</b>;\n");
let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect();
assert_eq!(members, ["app.activeDocument"]);
assert_eq!(problems(&r), [("newerSyntax", Some(1), Some("\\u{61}"))]);
assert!(tokens("var x = <a>{this.#\\u0061b}</a>;").contains(&(T::Other, "#\\u0061b")));
}
#[test]
@@ -1590,7 +1739,7 @@ fn include_lines_share_the_folders_of_the_include_path_before_them() {
let limits = Limits::default();
let mut acc = report::Acc::new(&limits);
let declared = analyze::declarations(&src, &mut acc);
let scan = analyze::scan(&src, &declared, None, std::rc::Rc::from([]), &analyze::Known::default(), &mut acc);
let scan = analyze::scan(&src, &declared, &[], None, std::rc::Rc::from([]), &analyze::Known::default(), &mut acc);
let (first, rest) = scan.includes.split_first().unwrap();
assert_eq!((first.paths.len(), rest.len()), (64, 999));
assert!(rest.iter().all(|i| std::rc::Rc::ptr_eq(&i.paths, &first.paths)), "one list of folders, not a copy for each line");
+7 -5
View File
@@ -502,15 +502,17 @@ pub fn param_keys(doc: &str) -> Vec<String> {
out
}
/// Parameter-name keys that name a file or folder a command may write: fuzzing these with
/// arbitrary strings could write files anywhere, so callers substitute a temp path.
/// Parameter-name keys that name a file or folder a command may write
/// ([`vectorcraft_engine::file_access::is_path_key`]): fuzzing these with arbitrary strings could
/// write files anywhere, so callers substitute a temp path.
pub fn is_path_key(k: &str) -> bool {
matches!(k, "path" | "diffPath") || is_folder_key(k)
vectorcraft_engine::file_access::is_path_key(k)
}
/// Path keys that name a folder a command creates or writes files into.
/// Path keys that name a folder a command creates or writes files into: `folder` or a name ending in
/// `Folder` (`destinationFolder`).
pub fn is_folder_key(k: &str) -> bool {
matches!(k, "folder" | "destinationFolder")
k == "folder" || k.ends_with("Folder")
}
/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for
+10
View File
@@ -341,6 +341,9 @@ impl Revolve {
pub struct Face {
pub points: Vec<Point>,
pub depth: f64,
/// The face's unit normal in view space (x right, y down, z toward the viewer), turned to the
/// side the viewer sees.
pub normal: [f64; 3],
pub brightness: f64,
pub gradient: Option<ShadeGradient>,
/// RGB illumination and its unclipped smooth ramp, independent of the legacy white ramp.
@@ -453,6 +456,7 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi
Ok(Some(Face {
points,
depth: vertices.iter().map(|v| v.2).sum::<f64>() / vertices.len() as f64,
normal: [normal.0, normal.1, normal.2],
brightness,
gradient,
illumination,
@@ -470,6 +474,12 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Fa
revolve_faces(path, FillRule::NonZero, bounds, options, None)
}
/// [`revolve`]'s faces as facets, back to front, for the classic surfaces: each keeps its
/// view-space normal, and its brightness and illumination are those of `options`.
pub fn revolve_facets(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Facet>, String> {
Ok(revolve(path, bounds, options)?.into_iter().map(Facet::of_face).collect())
}
const TOO_COMPLEX_PROFILE: &str = "Revolve profile is too complex";
const TOO_COMPLEX_SURFACE: &str = "Revolve surface is too complex; reduce Segments or simplify the profile";
+18
View File
@@ -294,6 +294,24 @@ impl Facet {
}
PathData::from_bezpath(&bp)
}
/// Revolve's face `face` as a facet: its polygon, curved outline, depth, normal and lighting.
/// A face that joins the profile's ends round the axis is a side; a cap is an end.
pub(crate) fn of_face(face: crate::Face) -> Self {
Facet {
contours: vec![face.points],
depth: face.depth,
brightness: face.brightness,
illumination: face.illumination,
normal: face.normal,
rgb_gradient: None,
outline: face.outline,
side: !face.cap,
away: false,
#[cfg(test)]
view: vec![],
}
}
}
fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 {
+29
View File
@@ -94,3 +94,32 @@ fn surfaces_round_trip_through_their_ids() {
assert_eq!(ClassicSurface::parse("glossy"), None);
assert_eq!(ClassicSurface::default(), ClassicSurface::Plastic);
}
#[test]
fn revolve_facets_keep_each_face_normal_turned_to_the_viewer() {
// A cylinder of radius 30 about the left edge, with closed ends, tipped toward the viewer.
let mut b = BezPath::new();
b.move_to((100.0, 0.0));
b.line_to((130.0, 0.0));
b.line_to((130.0, 100.0));
b.line_to((100.0, 100.0));
let path = PathData::from_bezpath(&b);
let options = Revolve { rotation_x: -30.0, shade: false, smooth_shading: false, ..Revolve::default() };
let faces = revolve(&path, path.bounds().unwrap(), options).unwrap();
let facets = revolve_facets(&path, path.bounds().unwrap(), options).unwrap();
assert_eq!(facets.len(), faces.len());
for (facet, face) in facets.iter().zip(&faces) {
let [x, y, z] = facet.normal;
assert!(((x * x + y * y + z * z).sqrt() - 1.0).abs() < 1e-9, "unit length: {:?}", facet.normal);
assert!(z >= 0.0, "turned to the viewer: {:?}", facet.normal);
assert_eq!((facet.depth, facet.side), (face.depth, !face.cap));
}
// Tipped 30°, the wall faces nearest the viewer turn 30° down from it, and the flat top and bottom
// (the profile's horizontal edges swept round) face up and out at 30°.
let (s, c) = 30f64.to_radians().sin_cos();
assert!(facets.iter().any(|f| f.normal[2] > c - 0.01 && (f.normal[1] - s).abs() < 0.01), "{facets:?}");
assert!(facets.iter().any(|f| (f.normal[1] + c).abs() < 1e-6 && (f.normal[2] - s).abs() < 1e-6), "{facets:?}");
// Moved off the axis, the profile's open ends close with caps, which are ends rather than sides.
let ring = revolve_facets(&path, path.bounds().unwrap(), Revolve { offset: 10.0, ..options }).unwrap();
assert!(ring.iter().any(|f| !f.side && (f.normal[1] + c).abs() < 1e-6 && (f.normal[2] - s).abs() < 1e-6));
}
+1
View File
@@ -293,6 +293,7 @@ mod tests {
Face {
points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)],
depth: brightness,
normal: [0.0, 0.0, 1.0],
brightness,
gradient: None,
illumination: [brightness; 3],
+7
View File
@@ -119,6 +119,13 @@ impl PointSnap {
Self { targets }
}
/// Snapping for a point placed anywhere, as a drawing tool's ([`DrawSnap`]) is: the art in the
/// window as targets ([`Targets::for_drawing`]), live text as Snap to Glyph asks.
pub fn drawn(cx: &ToolContext) -> Self {
let glyphs = (cx.snap_to_glyph && cx.smart_guides && cx.alignment_guides).then_some(cx.glyph_snap);
Self::new(cx, || Targets::for_drawing(cx.doc, &[], visible(cx), glyphs))
}
/// Where `p` goes, and the guides showing why.
pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
self.snap_from(cx, p, None)
+36 -2
View File
@@ -9,13 +9,14 @@
//! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards'
//! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its
//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden
//! or locked layer, can't be picked.
//! or locked layer, can't be picked. The rulers' origin, dragged out of their corner
//! ([`OriginDrag`]), snaps as a drawn point does.
use serde_json::json;
use vectorcraft_doc::{Guide, NodeId};
use vectorcraft_geom::{Point, Rect};
use crate::guides::Targets;
use crate::guides::{PointSnap, Targets};
use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext};
/// The ruler guide under `p`, by its index in `guide.list`: the nearest within the selection
@@ -247,6 +248,39 @@ impl NewGuide {
}
}
/// The rulers' origin dragged out of the box where they meet, with any tool: it snaps as a drawn
/// point does ([`PointSnap::drawn`]), with Smart Guides onto the anchors, centres, edges and paths
/// of the art and the artboards or into line with them, with Snap to Point onto the anchors and
/// along the ruler guides, else onto whole pixels or the grid. The targets are gathered when the
/// drag begins. It changes nothing: the origin is set where the button is released.
pub struct OriginDrag {
snap: PointSnap,
/// The guides showing what the origin snapped to.
guides: Vec<Overlay>,
}
impl OriginDrag {
pub fn new(cx: &ToolContext) -> Self {
Self { snap: PointSnap::drawn(cx), guides: vec![] }
}
/// Where the origin goes with the pointer at `p` (none off the canvas).
pub fn pointer(&mut self, cx: &ToolContext, p: Option<Point>) -> Option<Point> {
let Some(p) = p else {
self.guides.clear();
return None;
};
let (at, guides) = self.snap.snap(cx, p);
self.guides = guides;
Some(at)
}
/// The guides showing what the origin snapped to.
pub fn overlays(&self) -> Vec<Overlay> {
self.guides.clone()
}
}
/// A selection tool's ruler-guide handling: [`Self::press`] on a press, [`Self::pointer`] for
/// the rest of the gesture.
#[derive(Default)]
+6 -3
View File
@@ -157,9 +157,12 @@ impl VectorcraftApp {
let pending = self.pending_screenshots.remove(index);
self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes);
let roots = self.automation_roots.clone();
let _ = pending
.reply
.send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, pending.path.as_deref(), pending.data)));
let reply = file_access::confine(roots.as_ref(), || {
let mut reply = control::save_screenshot(self, &image, pending.path.as_deref(), pending.data);
control::hide_in_deferred_reply(self, &mut reply, &json!({"path": pending.path}));
reply
});
let _ = pending.reply.send(reply);
}
Err(e) => {
let error = format!("the screenshot could not be used: {e}");
+2 -2
View File
@@ -50,7 +50,7 @@ use fill::fill_uncovered;
use fill::{fill_view, polygon_area};
#[cfg(test)]
use grid::ruler_rects;
use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, textured_quad, view_bounds};
use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, rulers_hidden, textured_quad, view_bounds};
pub(crate) use grid::{RulerScale, checker_texture, ruler_label, ruler_origin_dragging};
#[cfg(test)]
pub(crate) use grid::{TRANSPARENCY_GRID, vertical_label_height};
@@ -283,7 +283,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.ctx().request_repaint_after_secs(secs as f32);
}
let hover_still = wait.is_none();
let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None };
let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { rulers_hidden(app, ui) };
crate::document_drag::drop_on_canvas(app, &resp, &xf);
panel_drop(app, ui, &resp, &xf);
// In Presentation Mode a right click or a long touch opens no context menu.
+17 -2
View File
@@ -53,7 +53,10 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf:
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
/// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries
/// (`appearance.copyFrom`), a graphic style is applied to it (`graphicStyle.apply`; with Alt, on
/// top of its appearance). A chip follows the pointer meanwhile.
/// top of its appearance), and a brush is applied to it (`brush.apply`, `brush.addFromLibrary`
/// for a brush library's). A Brushes panel scatter, art or pattern
/// brush dropped where there is no art places a copy of its art there (`brush.placeArt`). A chip
/// follows the pointer meanwhile.
pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) {
crate::panels::swatches::drag_preview(app, ui.ctx());
let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return };
@@ -86,7 +89,19 @@ pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respons
}
return;
}
let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return };
let hit = hit_at(app, xf.to_doc(pos), xf.zoom);
// A scatter, art or pattern brush from the Brushes panel dropped where there is no art: a copy
// of its art, centered where it is dropped, to edit and Alt-drag back onto the brush.
if let (None, widgets::PanelDrag::Brush { name, def }) = (&hit, &*d)
&& matches!(def["type"].as_str(), Some("scatter" | "art" | "pattern"))
{
let at = xf.to_doc(pos);
if let Err(e) = app.run("brush.placeArt", json!({"name": name, "x": at.x, "y": at.y})) {
app.status(e);
}
return;
}
let Some(hit) = hit else { return };
let Some(st) = app.session.active() else { return };
let (cmd, params) = match &*d {
widgets::PanelDrag::Paint { params, .. } => {
+4 -4
View File
@@ -293,9 +293,7 @@ pub(super) fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response,
let tool = app.session.tool_id();
let Some(st) = app.session.active().filter(|_| matches!(tool, "selection" | "directSelection" | "groupSelection")) else { return false };
// The tool's interaction while it moves (or Alt-copies) the selection.
if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") {
return false;
}
let Some(it) = st.interaction.as_ref().filter(|i| i.label == "Move" || i.label == "Copy") else { return false };
if tool != "selection" && st.selection.objects.iter().any(|id| st.selection.partial(*id).is_some() || st.selection.segments.contains_key(id)) {
return false;
}
@@ -305,7 +303,9 @@ pub(super) fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response,
if on_canvas(ui, resp, p) && ui.input(|i| i.pointer.has_pointer()) {
return false;
}
let ids = st.selection.objects.clone();
// The art the drag started with: the release rolls the move back to it, and an Alt-copy's
// copies, selected while it previews, go with that.
let ids = it.selection.objects.clone();
egui::DragAndDrop::set_payload(ui.ctx(), widgets::PanelDrag::Art(ids));
true
}
+29 -5
View File
@@ -181,8 +181,9 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
/// it makes none. A double-click on a ruler (both clicks on that ruler) makes a guide at the
/// pointer, across the ruler's axis (a vertical one on the top ruler, a horizontal one on the
/// left; swapped in a view turned 90°, where the rulers read the other document axis, and the
/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a
/// double-click.
/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags
/// the rulers' origin, snapped as a drawn point is (`Session::ruler_origin_drag`), or resets it on
/// a double-click.
pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> {
let [top, left, corner] = ruler_rects(full);
// Right-click on a ruler or the origin box: the document units, to swap between them as
@@ -274,9 +275,17 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
let mut preview = None;
if let Some((started_in, mut cancelled)) = ui.data(|d| d.get_temp::<(Option<u64>, bool)>(drag_id)) {
cancelled |= started_in != uid || ui.input(|i| !i.focused || i.key_pressed(egui::Key::Escape));
let at = corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p));
let pointer =
corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p));
// The origin snaps as a drawn point does (Smart Guides, Snap to Point, pixels, the grid).
let at = if cancelled {
app.session.end_ruler_origin_drag();
None
} else {
app.session.ruler_origin_drag(pointer, app.view_info())
};
if corner.drag_stopped_by(egui::PointerButton::Primary) {
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
end_ruler_origin_drag(app, ui);
if !cancelled
&& let Some(at) = at
&& let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)}))
@@ -284,7 +293,7 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
app.status(e.to_string());
}
} else if !ui.input(|i| i.pointer.primary_down()) {
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
end_ruler_origin_drag(app, ui);
} else {
ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled)));
if !cancelled {
@@ -295,6 +304,21 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
preview
}
/// The rulers are hidden (View › Rulers, Presentation Mode): a drag of their origin they were
/// showing for is cancelled, so its snapping guides don't stay on the canvas. Nothing previews.
pub(super) fn rulers_hidden(app: &mut VectorcraftApp, ui: &Ui) -> Option<Point> {
if ruler_origin_dragging(ui.ctx()) {
end_ruler_origin_drag(app, ui);
}
None
}
/// Ends a drag from the rulers' corner, released or cancelled: its state and its snapping guides go.
fn end_ruler_origin_drag(app: &mut VectorcraftApp, ui: &Ui) {
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(ruler_origin_drag_id()));
app.session.end_ruler_origin_drag();
}
/// The key of the ruler origin drag's state in egui's temporary data.
fn ruler_origin_drag_id() -> egui::Id {
egui::Id::unique("ruler-origin-drag")
+1 -1
View File
@@ -154,7 +154,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())),
}
// Dynamic Text turns on or off for type on a path and area type (Block), as the Type menu's item does;
// left out for point type and type in a thread
// left out for point type and type in a thread, which is VectorCraft's own rule
// (<https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html>).
if crate::menus::enabled(app, "type.dynamicText") {
items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none()));
+73
View File
@@ -6983,6 +6983,49 @@ fn a_brush_dropped_on_a_path_is_applied_to_it() {
assert_eq!(brush(&app).as_deref(), Some("Arrow"));
}
#[test]
fn a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap();
app.session.execute("select.none", &json!({})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let drop = |app: &mut VectorcraftApp, name: &str, at: Point| {
let def = app.session.execute("brush.get", &json!({"name": name})).unwrap();
let at = xf.to_screen(at);
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Brush { name: name.into(), def });
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]);
};
let undo = app.session.active().unwrap().history.undo.len();
// A calligraphic brush has no art: nothing.
drop(&mut app, "3 pt. Round", Point::new(200.0, 250.0));
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
// An art brush: a copy of its art, centered where it lands, selected.
drop(&mut app, "Arrow", Point::new(200.0, 250.0));
let st = app.session.active().unwrap();
assert_eq!(st.history.undo.len(), undo + 1);
assert_eq!(st.history.undo.last().unwrap().label, "Place Brush Art");
let [placed] = st.selection.objects.as_slice() else { panic!("one object selected: {:?}", st.selection.objects) };
let b = st.doc.node(*placed).unwrap().geometric_bounds().unwrap();
assert!((b.center() - Point::new(200.0, 250.0)).hypot() < 0.1, "{b:?}");
// A scatter brush and a pattern brush (its tiles, in one group) place their art too.
for (i, name) in ["Dots", "Chain"].into_iter().enumerate() {
let at = Point::new(80.0 + 200.0 * i as f64, 60.0);
drop(&mut app, name, at);
let st = app.session.active().unwrap();
assert_eq!(st.history.undo.len(), undo + 2 + i, "{name}");
let [placed] = st.selection.objects.as_slice() else { panic!("{name}: one object selected: {:?}", st.selection.objects) };
let n = st.doc.node(*placed).unwrap();
assert!((n.geometric_bounds().unwrap().center() - at).hypot() < 0.1, "{name}");
if name == "Chain" {
assert_eq!(n.children().unwrap()[0].name.as_deref(), Some("Side Tile"));
}
}
}
#[test]
fn a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -7166,6 +7209,36 @@ fn art_moved_off_the_canvas_waits_and_drops_on_the_panel() {
assert_eq!(st.history.undo.len(), undo);
}
/// Art Alt-dragged off the canvas (a copy while it moves there) reaches the panels as the art the
/// drag started with, which is what stays when the release over a panel takes the copy back.
#[test]
fn art_alt_dragged_off_the_canvas_reaches_the_panels_as_the_original() {
use egui::{Event, Modifiers};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
let id = vectorcraft_doc::NodeId(id);
app.select_tool("selection");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let before = app.session.active().unwrap().doc.clone();
let button = |pos: Pos2, pressed: bool| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT };
let start = xf.to_screen(Point::new(100.0, 100.0));
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(start), button(start, true)]);
frame(&mut app, &ctx, vec![Event::PointerMoved(start + vec2(40.0, 0.0))]);
let st = app.session.active().unwrap();
assert_eq!(st.interaction.as_ref().map(|i| i.label.as_str()), Some("Copy"));
assert!(!st.selection.objects.contains(&id), "the copy is selected while it moves");
frame(&mut app, &ctx, vec![Event::PointerMoved(pos2(850.0, 100.0))]);
assert_eq!(egui::DragAndDrop::payload::<widgets::PanelDrag>(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id])));
frame(&mut app, &ctx, vec![button(pos2(850.0, 100.0), false)]);
let st = app.session.active().unwrap();
assert!(st.interaction.is_none());
assert_eq!(*st.doc, *before, "the copy goes, the original stays");
assert!(st.doc.node(id).is_some());
}
/// #4233: the Direct and Group Selection tools hand art they move whole to the panels too (Asset
/// Export, Graphic Styles…), as the Selection tool does; the move waits and nothing is recorded.
#[test]
+14 -3
View File
@@ -72,8 +72,9 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
// With the macOS menu bar the menus are at the top of the screen instead.
let menus_end = if app.services.native_menu.is_some() { ui.cursor().min.x } else { menus::menu_bar(app, ui) };
// The right-side group fills the space after the menus from the right; when it runs
// short, Discord goes first (it is also under Help), then the search icon (the palette
// stays under its shortcut), then the workspace switcher narrows.
// short, Discord goes first (it is also under Help), then Explore tutorials (also
// Window › Learn), then the search icon (the palette stays under its shortcut), then
// the workspace switcher narrows.
let full = ui.max_rect();
let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() };
let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom()));
@@ -97,7 +98,9 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let icon = 28.0;
let search = switcher && room >= 64.0 + 8.0 + gap + icon;
let ws_w = ws_w.min(room - 8.0 - gap - if search { icon } else { 0.0 }).max(64.0);
let discord = search && room >= ws_w + 8.0 + gap + icon + 10.0 + gap + crate::community::discord_width(ui, false);
// Explore tutorials, beside the search icon, opens the Learn panel.
let learn = search && room >= ws_w + 8.0 + 2.0 * (gap + icon);
let discord = learn && room >= ws_w + 8.0 + 2.0 * (gap + icon) + 10.0 + gap + crate::community::discord_width(ui, false);
let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center)));
let ui = &mut rui;
// Workspace switcher: shows the current workspace, opens Window → Workspace.
@@ -127,6 +130,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
app.ui.palette_query.clear();
}
}
if learn {
let shown = crate::panels::learn::visible(&app.ui);
if widgets::icon_button(ui, "graduation-cap", tl!("Explore tutorials"), shown, icon).clicked()
&& let Err(e) = app.run("window.learn", json!({}))
{
app.ui.status = e;
}
}
if discord {
ui.add_space(10.0);
crate::community::discord_button(app, ui, false);
+3 -2
View File
@@ -248,8 +248,9 @@ pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) {
/// Most notes the status message's hover and access information list.
const MAX_LISTED_NOTES: usize = 30;
/// The notes behind the status message (the open's, [`crate::state::UiState::status_notes`]) when
/// there are several and the status bar still shows the message they came with, else none.
/// The notes behind the status message (an open's, a save's or an export's,
/// [`crate::state::UiState::status_notes`]) when there are several and the status bar still shows
/// the message they came with, else none.
fn status_notes(ui: &crate::state::UiState) -> &[String] {
let notes = ui.status_notes.as_ref().filter(|(status, notes)| *status == ui.status && notes.len() > 1);
notes.map(|(_, notes)| notes.as_slice()).unwrap_or_default()
+764 -47
View File
@@ -251,7 +251,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
fields.remove("floating_panels");
leave_out_unreadable_paths(fields);
}
let mut inspected = json!({
json!({
"scriptRunning": app.session.script_running(),
"scriptReport": app.session.script_report(),
"tool": app.session.tool_id(),
@@ -307,10 +307,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
// A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting
// for the person at the computer: what asked for it runs again when it answers.
"fileDialog": app.picks.waiting_kind(),
});
let ids = unreadable_library_ids(app);
hide_library_ids(&mut inspected, &ids);
inspected
})
}
/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel
@@ -322,33 +319,150 @@ const PANEL_FIELDS: [&str; 7] =
/// The ids of the loaded libraries (`loaded/<path>`) that the UI state refers to and whose file the
/// request may not read, each with the id a reply shows instead: `loaded/<file name>`, as a library
/// loaded without a path is named. The UI state refers to a library by its bare id in a library panel
/// and the Color Guide's limit, and by a panel id (`library:<kind>:loaded/<path>`) in the fields of
/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a
/// workspace's name) is not read. The ids are numbered with the session's loaded libraries as the
/// library commands number them ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so
/// that two libraries stay two and `ui.inspect`, `ui.docking` and the library commands show a library
/// under the same id.
fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
let mut ids = std::collections::BTreeSet::new();
/// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of
/// [`PANEL_FIELDS`], the workspaces' layouts and the keys of shortcut overrides (`panel:<panel id>`,
/// saved and in an open Keyboard Shortcuts dialog), and by either in the `library` and `panel` params of
/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an
/// action's name) is not read. The session's loaded libraries whose file the request may not read are
/// hidden too. The ids are numbered as the library commands number them
/// ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so that two libraries stay two and
/// `ui.inspect`, `ui.docking` and the library commands show a library under the same id.
fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding {
let mut rest = std::collections::BTreeSet::new();
let ui = serde_json::to_value(&app.ui).unwrap_or_default();
for field in PANEL_FIELDS {
collect_library_panel_ids(&ui[field], &mut ids);
collect_library_panel_ids(&ui[field], &mut rest);
}
for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() {
for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") {
collect_library_panel_ids(layout, &mut ids);
// A shortcut for showing a panel is keyed `panel:<id>`, saved and in an open Keyboard Shortcuts dialog.
for overrides in [&ui["shortcut_overrides"], &ui["dialog"]["fields"]["overrides"]] {
for panel in overrides.as_object().into_iter().flat_map(|o| o.keys()).filter_map(|key| key.strip_prefix("panel:")) {
collect_library_panel_ids(&json!(panel), &mut rest);
}
}
collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids);
collect_library_params(&ui["action_sets"], &mut rest);
for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() {
for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") {
collect_library_panel_ids(layout, &mut rest);
}
}
collect_library_panel_ids(&json!(app.ui.transient_panel), &mut rest);
let mut ids = rest;
for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) {
add_library_id(id, &mut ids);
}
let mut hidden = vectorcraft_engine::cmd::swatchlib::concealed_library_ids(&app.session, ids.iter().cloned());
// Only the ids the UI state refers to are replaced in the reply.
hidden.retain(|(id, _)| ids.contains(id));
// The other loaded library ids the session and the UI state hold.
let s = &app.session;
let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos());
let readable_ids = loaded
.map(|info| info.id.clone())
.chain(ids)
.filter(|id| id.len() > "loaded/".len() && id.starts_with("loaded/") && !hidden.iter().any(|(h, _)| h == id))
.collect();
// The longest first, so that an id that begins another one does not replace part of it.
hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len()));
hidden
Hiding { hidden, readable: readable_ids, sent: Vec::new() }
}
/// What a reply hides ([`hide_library_ids`]).
#[derive(Debug, Default)]
struct Hiding {
/// The ids of the loaded libraries the request may not read, longest first, each with the id
/// shown instead ([`unreadable_library_ids`]).
hidden: Vec<(String, String)>,
/// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or
/// path that begins one of them stays ([`begins_known_id`]).
readable: std::collections::BTreeSet<String>,
/// The paths outside the read roots that the request sent ([`sent_paths`]), each as a hidden
/// library id with the id shown. They are matched after the hidden libraries.
sent: Vec<(String, String)>,
}
impl Hiding {
/// Adds the hidden and readable ids of `other` that this one does not hold.
fn merge(&mut self, other: Hiding) {
self.add_hidden(other.hidden);
self.readable.extend(other.readable);
}
/// Adds the hidden ids of `pairs` that this one does not hold, each with the id shown.
fn add_hidden(&mut self, pairs: Vec<(String, String)>) {
for pair in pairs {
if !self.hidden.iter().any(|(id, _)| *id == pair.0) {
self.hidden.push(pair);
}
}
self.hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len()));
}
/// Adds the sent paths of `pairs`, each as a hidden id with the id shown. A hidden id this one holds
/// for the same path (written with either separator when it is a Windows path
/// ([`splits_at_backslash`]), relative or as the absolute path it reads as ([`absolute_form`]), and
/// in any case of ASCII letters with `fold`) is shown as the sent path is.
fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) {
let key = |id: &str| {
let path = id.strip_prefix("loaded/").unwrap_or(id);
let id = absolute_form(path).unwrap_or_else(|| path.to_string());
let windows = splits_at_backslash(&id);
let id = if windows { id.replace('\\', "/") } else { id };
(windows, if fold { id.to_ascii_lowercase() } else { id })
};
let sent: Vec<(bool, String)> = pairs.iter().map(|(id, _)| key(id)).collect();
for (id, shown) in &mut self.hidden {
if let Some(at) = sent.iter().position(|k| *k == key(id)) {
shown.clone_from(&pairs[at].1);
}
}
for pair in pairs {
if !self.sent.iter().any(|(id, _)| *id == pair.0) {
self.sent.push(pair);
}
}
}
}
/// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths
/// ([`unreadable_path_like`]) or held by a path param
/// ([`vectorcraft_engine::file_access::is_path_key`]), a relative one too, each as a hidden library id
/// (`loaded/<path>`) with `loaded/<file name>` shown. A reply hides them as it hides a hidden library's
/// path, with that file name also for a hidden library shown as `loaded/<file name> (2)`, so that it
/// reads the same whether or not a library was loaded from one of them.
fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) {
collect_sent_paths(v, false, out);
}
/// [`sent_paths`] on `v`, which a path param holds when `path_param` is set.
fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String)>) {
fn add(s: &str, path_param: bool, out: &mut Vec<(String, String)>) {
if !unreadable_path_like(s) && !(path_param && !readable_value(s)) {
return;
}
// A URL that names another host as the path after its scheme (`//host/share/…`).
let path = match file_url_path(s) {
Some(Some(path)) => path,
Some(None) => percent_decoded(&s[5..]),
None => s.to_string(),
};
let name = vectorcraft_engine::cmd::swatchlib::concealed_file_name(&path);
if name == path {
return;
}
let pair = (format!("loaded/{path}"), format!("loaded/{name}"));
if !out.contains(&pair) {
out.push(pair);
}
}
match v {
Value::String(s) => add(s, path_param, out),
Value::Array(items) => items.iter().for_each(|item| collect_sent_paths(item, path_param, out)),
Value::Object(fields) => {
for (key, item) in fields {
add(key, false, out);
collect_sent_paths(item, vectorcraft_engine::file_access::is_path_key(key), out);
}
}
_ => {}
}
}
/// Adds `id` to `ids` when it is a loaded library's id (`loaded/<path>`).
@@ -379,33 +493,358 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str
}
}
/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's
/// id, in its panel's id (`library:swatches:loaded/<path>`) and in keys.
fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) {
if ids.is_empty() {
/// Writes each id of `ids` as the id the reply shows instead, and the path in it as the file name
/// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id
/// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's
/// report and a recorded step. An occurrence that begins another loaded library's id stays
/// ([`begins_known_id`]): a readable library's id that begins with a hidden one keeps its path. On
/// Windows the ids and paths are matched in any case of ASCII letters.
fn hide_library_ids(v: &mut Value, hiding: &Hiding) {
let pairs = hidden_text(hiding);
if pairs.iter().all(Vec::is_empty) {
return;
}
let hide = |s: &mut String| {
for (id, shown) in ids {
if s.contains(id.as_str()) {
*s = s.replace(id.as_str(), shown);
replace_in_value(v, &Rewrite { pairs, hiding, fold: cfg!(windows) });
}
/// The text [`hide_library_ids`] replaces, in two groups: the hidden libraries' text, then the text of
/// the paths the request sent ([`Hiding::sent`]), each longest first. For each id, that is the id with
/// the id shown, and the path in it with the file name shown, a Windows path ([`splits_at_backslash`])
/// written with either separator too, and as `{:?}` quotes it (each `\`, quote and control character
/// escaped). A relative path is also replaced as the absolute path the file access check reads it as,
/// against the working folder.
fn hidden_text(hiding: &Hiding) -> Vec<Vec<(String, String)>> {
vec![text_pairs(&hiding.hidden), text_pairs(&hiding.sent)]
}
/// Is `path` a Windows path, whose parts a `\` separates as well as a `/`: any path on Windows, and
/// elsewhere one that starts with a drive (`C:`) or a `\`? Outside Windows, a `\` in any other path is
/// part of a name.
fn splits_at_backslash(path: &str) -> bool {
cfg!(windows) || path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic())
}
/// The absolute path that `path`, a relative path (one that does not start with a separator, a drive
/// or `~`), reads as from the working folder, as the file access check reads it; `None` for any other
/// path.
fn absolute_form(path: &str) -> Option<String> {
let relative = !path.starts_with(['/', '\\', '~']) && !matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic());
relative.then(|| std::path::absolute(path).ok()).flatten().map(|p| p.to_string_lossy().into_owned())
}
/// The text [`hidden_text`] replaces for the ids of `ids`, longest first.
fn text_pairs(ids: &[(String, String)]) -> Vec<(String, String)> {
let quoted = |s: &str| {
let q = format!("{s:?}");
q[1..q.len() - 1].to_string()
};
let mut pairs: Vec<(String, String)> = Vec::new();
for (id, shown) in ids {
let path = &id["loaded/".len()..];
let name = shown.strip_prefix("loaded/").unwrap_or(shown);
for path in std::iter::once(path.to_string()).chain(absolute_form(path)) {
// A Windows path is also written with the other separator; outside Windows, a native
// path's `\` is part of a name, so that path is written only as it is.
let mut spellings = vec![path.clone()];
if splits_at_backslash(&path) {
spellings.extend([path.replace('\\', "/"), path.replace('/', "\\")].into_iter().filter(|p| splits_at_backslash(p)));
}
let debug: Vec<_> = spellings.iter().map(|p| (quoted(p), quoted(name))).collect();
let plain = spellings.into_iter().map(|p| (p, name.to_string()));
for (path, name) in plain.chain(debug) {
for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] {
if !pairs.iter().any(|(f, _)| *f == from) {
pairs.push((from, to));
}
}
}
}
};
}
pairs.sort_by_key(|(from, _)| std::cmp::Reverse(from.len()));
pairs
}
/// What [`replace_in_value`] writes: the groups of text to replace ([`hidden_text`]), what the reply
/// hides, and whether to match in any case of ASCII letters.
struct Rewrite<'a> {
pairs: Vec<Vec<(String, String)>>,
hiding: &'a Hiding,
fold: bool,
}
/// The keys of encoded file data in a reply.
const BASE64_FIELDS: [&str; 3] = ["dataBase64", "pngBase64", "thumbnailBase64"];
/// Is `s` written in the base64 alphabet, padded to a multiple of 4?
fn is_base64(s: &str) -> bool {
let body = s.trim_end_matches('=');
s.len().is_multiple_of(4) && s.len() - body.len() <= 2 && body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
}
/// Writes each `from` text of the rewrite's pairs as its `to` text in every string and key of `v`, as
/// [`replace_text`] does. Encoded file data, a base64 string under a key of [`BASE64_FIELDS`], stays
/// as it is.
fn replace_in_value(v: &mut Value, r: &Rewrite) {
match v {
Value::String(s) => hide(s),
Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)),
Value::String(s) => {
if let Some(replaced) = replace_text(s, r) {
*s = replaced;
}
}
Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, r)),
Value::Object(fields) => {
for (mut key, mut item) in std::mem::take(fields) {
hide(&mut key);
hide_library_ids(&mut item, ids);
fields.insert(key, item);
for (key, mut item) in std::mem::take(fields) {
if BASE64_FIELDS.contains(&key.as_str()) && item.as_str().is_some_and(is_base64) {
fields.insert(key, item);
continue;
}
replace_in_value(&mut item, r);
fields.insert(replace_text(&key, r).unwrap_or(key), item);
}
}
_ => {}
}
}
/// The byte offsets in `text` at which `needle` starts, not overlapping, in any case of ASCII letters
/// with `fold`.
fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> {
if needle.is_empty() {
return Vec::new();
}
if fold {
let (text, needle) = (text.to_ascii_lowercase(), needle.to_ascii_lowercase());
text.match_indices(needle.as_str()).map(|(at, _)| at).collect()
} else {
text.match_indices(needle).map(|(at, _)| at).collect()
}
}
/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence
/// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written
/// with `%XX` escapes (in a `file:` URL) is replaced too; the escapes around it stay. Every occurrence
/// is found in `s` as it was given, the hidden libraries' text before the sent paths' text, each group
/// longest `from` first, and one that overlaps an occurrence already found, or a loaded library's id
/// that one began, stays: a replacement's text is not searched again. With the rewrite's `fold`, the
/// ids and paths of a group as written are found first, so that two libraries whose paths differ only
/// in letter case keep their own ids, and then the ones written in another case. `None` when nothing
/// changed.
fn replace_text(s: &str, r: &Rewrite) -> Option<String> {
let decoded = if s.contains('%') { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None };
let mut found = Found::default();
for group in &r.pairs {
for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) {
for (from, to) in group {
if let Some((text, spans)) = &decoded {
found.claim(text, Some(spans.as_slice()), from, to, fold, r);
}
found.claim(s, None, from, to, fold, r);
}
}
}
if found.cuts.is_empty() {
return None;
}
let mut cuts = found.cuts;
cuts.sort_unstable_by_key(|&(start, _, _)| start);
let mut out = String::with_capacity(s.len());
let mut copied = 0;
for (start, stop, to) in cuts {
out.push_str(&s[copied..start]);
out.push_str(to);
copied = stop;
}
out.push_str(&s[copied..]);
Some(out)
}
/// The spans of a text that [`replace_text`] replaces, each with its replacement, and every span it has
/// taken: the ones it replaces and the loaded library ids it keeps.
#[derive(Default)]
struct Found<'a> {
cuts: Vec<(usize, usize, &'a str)>,
taken: Vec<(usize, usize)>,
}
impl<'a> Found<'a> {
/// Takes each occurrence of `from` in `text` that overlaps no span taken: as a span to write as `to`,
/// or, where it begins a loaded library's id ([`begins_known_id`]), that id's span, kept. `spans`
/// gives the range of the original text each byte of a decoded `text` was read from.
fn claim(&mut self, text: &str, spans: Option<&[(usize, usize)]>, from: &str, to: &'a str, fold: bool, r: &Rewrite) {
let span = |at: usize, end: usize| spans.map_or((at, end), |s| (s[at].0, s[end - 1].1));
for at in occurrences(text, from, fold) {
let end = at + from.len();
let (start, stop) = span(at, end);
if self.taken.iter().any(|&(a, b)| start < b && a < stop) || ends_a_longer_path(text, at, from) {
continue;
}
match begins_known_id(text, at, end, r) {
Some(len) => self.taken.push(span(at, at + len)),
None => {
self.taken.push((start, stop));
self.cuts.push((start, stop, to));
}
}
}
}
}
/// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text`
/// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD.
fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut spans = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let hex = bytes
.get(i + 1..i + 3)
.filter(|h| bytes[i] == b'%' && h.iter().all(u8::is_ascii_hexdigit))
.and_then(|h| std::str::from_utf8(h).ok())
.and_then(|h| u8::from_str_radix(h, 16).ok());
match hex {
Some(v) => {
out.push(v);
spans.push((i, i + 3));
i += 3;
}
None => {
out.push(bytes[i]);
spans.push((i, i + 1));
i += 1;
}
}
}
let mut decoded = String::with_capacity(out.len());
let mut decoded_spans = Vec::with_capacity(spans.len());
let mut at = 0;
for chunk in out.utf8_chunks() {
decoded.push_str(chunk.valid());
decoded_spans.extend_from_slice(spans.get(at..at + chunk.valid().len())?);
at += chunk.valid().len();
for _ in chunk.invalid() {
decoded.push(char::REPLACEMENT_CHARACTER);
let span = *spans.get(at)?;
decoded_spans.extend([span; 3]);
at += 1;
}
}
Some((decoded, decoded_spans))
}
/// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at
/// `text[at..]` the end of a longer path: after any `.` and `..` parts just before it, is it preceded by
/// a letter, a digit, `_`, `-`, `.`, `~` or a separator? `/work/data/icon.svg` and
/// `/work/./data/icon.svg` do not hold the path `data/icon.svg`; `./data/icon.svg` and
/// `../data/icon.svg` do.
fn ends_a_longer_path(text: &str, at: usize, from: &str) -> bool {
let absolute =
from.starts_with(['/', '\\']) || from.starts_with("loaded/") || matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic());
let mut before = &text[..at];
while let Some(head) = before.strip_suffix(['/', '\\']) {
let Some(rest) = head.strip_suffix("..").or_else(|| head.strip_suffix('.')) else { break };
before = rest;
if !before.ends_with(['/', '\\']) {
break;
}
}
!absolute && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\'))
}
/// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`,
/// `-` or `~`? A space, punctuation, a quote, a sentence's final period and the end of the text end it.
fn continues_path(rest: &str) -> bool {
rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\'))
}
/// Does the occurrence `text[at..end]` begin a longer loaded library id ([`Hiding::readable`]), written
/// with or without its `loaded/` and with no `.` or `..` part, that ends where the text stops
/// continuing a path ([`continues_path`])? The length of that id as written, when it does.
/// A readable library's id that begins with a hidden one stays, also with a space after the hidden
/// part; a hidden path followed by other path text (`<path>.bak`, `<path>/../x`) is still hidden.
fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> Option<usize> {
let rest = &text[at..];
let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b };
let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == "..");
let prefixed = rest.get(.."loaded/".len()).is_some_and(|head| same(head, "loaded/"));
r.hiding.readable.iter().filter(|known| !dotted(known)).find_map(|known| {
let id = if prefixed { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) };
let begins = id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]);
begins.then_some(id.len())
})
}
/// Text that a person or a script wrote, by its key: a command palette query, a script's report and a
/// workspace's name. [`swap_library_ids`] leaves it as written.
const TEXT_FIELDS: [&str; 3] = ["palette_query", "scriptReport", "name"];
/// Writes each `from` id of `pairs` as its `to` id wherever `v` holds it as a whole id ([`swap_whole_ids`]),
/// in strings and keys, except in the [`TEXT_FIELDS`].
fn swap_library_ids(v: &mut Value, pairs: &[(&str, &str)]) {
if pairs.is_empty() {
return;
}
match v {
Value::String(s) => {
if let Some(swapped) = swap_whole_ids(s, pairs) {
*s = swapped;
}
}
Value::Array(items) => items.iter_mut().for_each(|item| swap_library_ids(item, pairs)),
Value::Object(fields) => {
for (key, mut item) in std::mem::take(fields) {
if TEXT_FIELDS.contains(&key.as_str()) {
fields.insert(key, item);
continue;
}
swap_library_ids(&mut item, pairs);
fields.insert(swap_whole_ids(&key, pairs).unwrap_or(key), item);
}
}
_ => {}
}
}
/// `s` with each `from` id of `pairs` written as its `to` id where it is a whole id: the whole string,
/// or a part that starts the string or follows a `:`, `"` or `` ` `` (a panel id, a quoted id) and ends
/// the string or a quote. An id that only begins or ends a longer one stays. `None` when nothing changed.
fn swap_whole_ids(s: &str, pairs: &[(&str, &str)]) -> Option<String> {
let mut out: Option<String> = None;
for (from, to) in pairs {
let text = out.as_deref().unwrap_or(s);
if !text.contains(from) {
continue;
}
let mut next = String::with_capacity(text.len());
let mut copied = 0;
for (at, _) in text.match_indices(from) {
let end = at + from.len();
let starts = at == 0 || text[..at].ends_with([':', '"', '`']);
let ends = end == text.len() || text[end..].starts_with(['"', '`']);
if at >= copied && starts && ends {
next.push_str(&text[copied..at]);
next.push_str(to);
copied = end;
}
}
if copied > 0 {
next.push_str(&text[copied..]);
out = Some(next);
}
}
out
}
/// Does `v` hold a string or a key that names a loaded library (`loaded/…`)?
fn names_a_loaded_library(v: &Value) -> bool {
match v {
Value::String(s) => s.contains("loaded/"),
Value::Array(items) => items.iter().any(names_a_loaded_library),
Value::Object(fields) => fields.iter().any(|(key, item)| key.contains("loaded/") || names_a_loaded_library(item)),
_ => false,
}
}
/// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog.
const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
@@ -414,14 +853,146 @@ const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
/// May the request being served read `path`? Under an automation read root (a control client's
/// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent
/// file or a listed script outside it.
/// file or a listed script outside it. Another platform's absolute path
/// ([`vectorcraft_engine::file_access::written_elsewhere`]) is never readable under a read root.
fn readable(path: &str) -> bool {
vectorcraft_engine::file_access::check_read(path).is_ok()
!vectorcraft_engine::file_access::written_elsewhere(path) && vectorcraft_engine::file_access::check_read(path).is_ok()
}
/// Is `s` a path the request may not read, written as an absolute path (either separator, with or
/// without a drive) or a `file:` URL ([`file_url_path`])? Under a read root, a `file:` URL that names
/// another host is never readable.
fn unreadable_absolute(s: &str) -> bool {
match file_url_path(s) {
Some(path) => !readable_url_path(path),
None => {
let absolute = s.starts_with(['/', '\\']) || matches!(s.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic());
absolute && !readable(s)
}
}
}
/// Is `s` written as a path that the request may not read: an absolute path or a `file:` URL
/// ([`unreadable_absolute`]), `~` or a path under it, or a path that starts with `.` or `..` and a
/// separator? It is checked as the file access check reads it, relative to the working folder. Other
/// text with a separator (`a/b`) stays.
fn unreadable_path_like(s: &str) -> bool {
if unreadable_absolute(s) {
return true;
}
let t = s.trim_start();
let relative = t.trim_end() == "~" || ["~/", "~\\", "./", ".\\", "../", "..\\"].iter().any(|p| t.starts_with(p));
relative && !readable_value(s)
}
/// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])?
fn readable_value(s: &str) -> bool {
match file_url_path(s) {
Some(path) => readable_url_path(path),
None => readable(s),
}
}
/// May the request read the local path a `file:` URL names ([`file_url_path`])? A URL that names
/// another host may be read only without a read root in force.
fn readable_url_path(path: Option<String>) -> bool {
match path {
Some(path) => readable(&path),
None => vectorcraft_engine::file_access::current().is_none(),
}
}
/// The local path that `s` names when it is a `file:` URL, with the scheme in any case and its `%XX`
/// escapes decoded: `Some(None)` for a URL that names another host (`file://host/share`).
/// `file:///C:/x` names `C:/x`.
fn file_url_path(s: &str) -> Option<Option<String>> {
let rest = s.get(..5).filter(|scheme| scheme.eq_ignore_ascii_case("file:")).map(|_| &s[5..])?;
let Some(rest) = rest.strip_prefix("//") else { return Some(Some(percent_decoded(rest))) };
let rest = if rest.get(..9).is_some_and(|host| host.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest };
if !rest.starts_with('/') {
return Some(None);
}
let path = match rest.as_bytes() {
[b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => &rest[1..],
_ => rest,
};
Some(Some(percent_decoded(path)))
}
/// `s` with `%XX` escapes decoded, as the SVG importer decodes an href.
fn percent_decoded(s: &str) -> String {
if !s.contains('%') {
return s.to_string();
}
let mut out = Vec::with_capacity(s.len());
let mut rest = s.as_bytes();
while let Some((&b, tail)) = rest.split_first() {
let hex = tail
.get(..2)
.filter(|h| h.iter().all(u8::is_ascii_hexdigit))
.and_then(|h| std::str::from_utf8(h).ok())
.and_then(|h| u8::from_str_radix(h, 16).ok());
match hex {
Some(v) if b == b'%' => {
out.push(v);
rest = tail.get(2..).unwrap_or_default();
}
_ => {
out.push(b);
rest = tail;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
/// The params of each step of each action of action set `set` (`ui.action_sets`).
fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<String, Value>> {
let actions = set.get_mut("actions").and_then(Value::as_array_mut).into_iter().flatten();
let steps = actions.filter_map(|action| action.get_mut("steps").and_then(Value::as_array_mut)).flatten();
steps.filter_map(|step| step.get_mut(1).and_then(Value::as_object_mut))
}
/// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the
/// paths the request may not read: the value of a path param
/// ([`vectorcraft_engine::file_access::is_path_key`]: `path`, `file`, `diffPath`, `destinationFolder`
/// and the like), each path of a list under such a param, and under any other param, as a key too, a
/// string written as a path ([`unreadable_path_like`]).
fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) {
use vectorcraft_engine::file_access::is_path_key;
params.retain(|key, v| {
!unreadable_path_like(key)
&& match v.as_str() {
Some(p) => !unreadable_path_like(p) && !(is_path_key(key) && !readable_value(p)),
None => true,
}
});
for (key, v) in params.iter_mut() {
if let Value::Array(paths) = v
&& is_path_key(key)
{
paths.retain(|p| p.as_str().is_none_or(readable_value));
}
leave_out_unreadable_values(v);
}
}
/// [`leave_out_unreadable_params`] on each object `v` holds, and a string written as a path that the
/// request may not read ([`unreadable_path_like`]) taken out of each list.
fn leave_out_unreadable_values(v: &mut Value) {
match v {
Value::Object(fields) => leave_out_unreadable_params(fields),
Value::Array(items) => {
items.retain(|item| !item.as_str().is_some_and(unreadable_path_like));
items.iter_mut().for_each(leave_out_unreadable_values);
}
_ => {}
}
}
/// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files,
/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside
/// the read root. The UI state itself is not changed.
/// a library panel's file, folder preferences (saved, and in an open Preferences dialog) and the paths
/// in recorded actions' steps outside the read root. The UI state itself is not changed.
fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
if let Some(Value::Array(files)) = ui.get_mut("recent_files") {
files.retain(|f| f.as_str().is_some_and(readable));
@@ -433,6 +1004,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
}
}
}
for step in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) {
leave_out_unreadable_params(step);
}
if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") {
leave_out_unreadable_folders(prefs);
}
@@ -448,8 +1022,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
/// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay.
fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) {
for key in PREF_FOLDERS {
let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue };
if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) {
let Some(written) = prefs.get(key).and_then(Value::as_str) else { continue };
let path = written.trim();
if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(written) {
continue;
}
prefs.remove(key);
@@ -494,7 +1069,142 @@ fn packet_mods(value: Option<&Value>) -> Result<Option<Mods>, String> {
}
}
/// The params that name a library or a library panel, at any depth of a request (`ui.set {panel}`,
/// `engine.execute {params: {panel}}`, `ui.libraryPanel {panel, library}`), with the docking
/// commands' destinations (`window.panel.move {anchor, before}`, `ui.dock {target}`,
/// `window.panel.float {onto, above, below}`) and a list of panels. Only a whole id is translated
/// ([`swap_whole_ids`]), so other text in these params stays.
const LIBRARY_PARAMS: [&str; 9] = ["panel", "library", "anchor", "target", "before", "onto", "above", "below", "panels"];
/// The param of `window.panel.layout` that holds a serialized docking action: each panel id in it is
/// translated.
const DOCKING_ACTION: &str = "action";
/// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect`
/// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id
/// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each
/// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key,
/// errors included, an error from a panic too: the libraries hidden before it ran and after it. The
/// reply also shows each path outside the read root that the request sent by its file name, whether it
/// succeeded or failed ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path
/// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent
/// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id
/// or path.
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
if vectorcraft_engine::file_access::current().is_none() {
return dispatch(app, ctx, req);
}
// Pointer, key and text input does not load, close or rename a library: the libraries hidden after
// it ran are the ones hidden before, so frequent input does not read the UI state twice.
let before = if INPUT_METHODS.contains(&req.method.as_str()) { Hiding::default() } else { unreadable_library_ids(app) };
let shown: Vec<_> = before.hidden.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect();
let mut params = req.params.clone();
let swapped = !shown.is_empty() && names_a_loaded_library(&req.params) && swap_library_params(&mut params, &shown);
let ran = vectorcraft_engine::guard::catch_panic(|| {
if swapped {
let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq };
dispatch(app, ctx, &request)
} else {
dispatch(app, ctx, req)
}
});
let outcome = match ran {
Ok(outcome) => outcome,
Err(msg) => {
app.picks.recover();
app.session.set_replaying(false);
err(format!("internal error: {msg} (please report this bug)"))
}
};
let Outcome::Done(mut reply) = outcome else { return outcome };
hide_in_reply(app, &mut reply, &req.params, before);
Outcome::Done(reply)
}
/// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that
/// `sent`, the request's params, holds ([`handle`]).
fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) {
if !holds_a_separator(reply) {
return;
}
let mut hiding = unreadable_library_ids(app);
hiding.merge(before);
let mut paths = Vec::new();
sent_paths(sent, &mut paths);
hiding.set_hidden(paths, cfg!(windows));
hide_library_ids(reply, &hiding);
}
/// Hides in a reply sent after its request was handled (a screenshot's) what [`handle`] hides in a
/// reply, with `sent` as the request's params. Without a read root in force it leaves the reply as
/// it is.
pub(crate) fn hide_in_deferred_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value) {
if vectorcraft_engine::file_access::current().is_some() {
hide_in_reply(app, reply, sent, Hiding::default());
}
}
/// The control methods that send synthetic input.
const INPUT_METHODS: [&str; 7] = ["ui.pointer", "ui.click", "ui.drag", "ui.move", "ui.wheel", "ui.key", "ui.text"];
/// Does a string or a key of `v`, at any depth, hold a `/`, a `\` or a `%`?
fn holds_a_separator(v: &Value) -> bool {
let has = |s: &str| s.contains(['/', '\\', '%']);
match v {
Value::String(s) => has(s),
Value::Array(items) => items.iter().any(holds_a_separator),
Value::Object(fields) => fields.iter().any(|(key, item)| has(key) || holds_a_separator(item)),
_ => false,
}
}
/// Writes each `from` id of `pairs` in the [`LIBRARY_PARAMS`] params of `params`, at any depth, as its
/// `to` id → whether one was.
fn swap_library_params(params: &mut Value, pairs: &[(&str, &str)]) -> bool {
match params {
Value::Array(items) => items.iter_mut().fold(false, |swapped, item| swap_library_params(item, pairs) | swapped),
Value::Object(fields) => fields.iter_mut().fold(false, |swapped, (key, item)| {
let this = match item {
Value::String(s) if LIBRARY_PARAMS.contains(&key.as_str()) => swap_whole_ids(s, pairs).map(|to| *s = to).is_some(),
Value::Array(items) if LIBRARY_PARAMS.contains(&key.as_str()) => items.iter_mut().fold(false, |swapped, item| {
let this = item.as_str().and_then(|s| swap_whole_ids(s, pairs)).map(|to| *item = Value::String(to)).is_some();
this | swapped
}),
Value::Object(_) if key == DOCKING_ACTION => {
let before = item.clone();
swap_library_ids(item, pairs);
*item != before
}
_ => swap_library_params(item, pairs),
};
this | swapped
}),
_ => false,
}
}
/// Adds to `ids` the loaded library ids that the [`LIBRARY_PARAMS`] params of `v` name, at any depth:
/// a library's id or its panel's id.
fn collect_library_params(v: &Value, ids: &mut std::collections::BTreeSet<String>) {
match v {
Value::Array(items) => items.iter().for_each(|item| collect_library_params(item, ids)),
Value::Object(fields) => {
for (key, item) in fields {
if !LIBRARY_PARAMS.contains(&key.as_str()) {
collect_library_params(item, ids);
continue;
}
for s in item.as_str().into_iter().chain(item.as_array().into_iter().flatten().filter_map(Value::as_str)) {
add_library_id(s, ids);
collect_library_panel_ids(&json!(s), ids);
}
}
}
_ => {}
}
}
fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
match req.method.as_str() {
@@ -511,12 +1221,19 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
"engine.commands" => ok(all_commands(app)),
"document.inspect" => wrap(app.run("document.inspect", p.clone())),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.docking" => {
let mut docking = crate::panel_docking::inspect(app, ctx);
let ids = unreadable_library_ids(app);
hide_library_ids(&mut docking, &ids);
ok(docking)
"ui.docking" => ok(crate::panel_docking::inspect(app, ctx)),
// A request that stops with a panic quoting the status text, after clearing the Color Guide's
// limit with `forget`.
#[cfg(test)]
"test.panic" => {
if p.get("forget").and_then(Value::as_bool) == Some(true) {
app.ui.color_guide_limit.clear();
}
panic!("failed on {}", app.ui.status)
}
// A request that succeeds with the status text.
#[cfg(test)]
"test.status" => ok(json!(app.ui.status)),
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
"ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()),
"ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
+350
View File
@@ -12,6 +12,356 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value
r
}
/// A reply hides a hidden library's id and path at every occurrence, in an error that wraps them in
/// spaces, brackets or quotes, follows them with more path text, or quotes the path with `{:?}`.
#[test]
fn a_reply_hides_a_hidden_library_whatever_surrounds_it() {
let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned();
let id = format!("loaded/{path}");
let hidden = hiding(&[(&id, "loaded/b.svg")], &[]);
let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")});
hide_under_a_root("named-reply", &mut reply, &hidden);
assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}");
}
/// A [`super::Hiding`] with `hidden` ids, each with its shown id, and `readable` ids.
fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding {
let mut hidden: Vec<(String, String)> = hidden.iter().map(|(id, shown)| (id.to_string(), shown.to_string())).collect();
hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len()));
super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect(), sent: Vec::new() }
}
/// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`.
fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) {
let root = std::env::temp_dir().join(format!("vc-{tag}-root-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(reply, hidden));
let _ = std::fs::remove_dir_all(root);
}
/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64` or
/// `thumbnailBase64`, stays as it is when it holds a hidden library's path. Text that is not base64
/// under such a key, an object or a list under it, and any other key ending in `Base64` are searched.
#[test]
fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() {
let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]);
let data = "QUJ+assets/iconREVGxQQ==";
let mut reply = json!({"ok": true, "result": {
"files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}],
"status": "from assets/icon",
"dataBase64": {"note": "from assets/icon"},
"pngBase64": ["from assets/icon"],
"otherBase64": data,
}});
hide_under_a_root("base64", &mut reply, &hidden);
let files = &reply["result"]["files"][0];
assert_eq!((&files["dataBase64"], &files["thumbnailBase64"]), (&json!(data), &json!(data)), "{reply}");
assert_eq!(files["pngBase64"], "icon.png", "{reply}");
assert_eq!(reply["result"]["status"], "from icon", "{reply}");
assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}");
assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}");
assert_eq!(reply["result"]["otherBase64"], "QUJ+iconREVGxQQ==", "{reply}");
}
/// A hidden path followed by more text stays only when the longer text is another loaded library's
/// id: text after the path that the request may read, a `..` part and the part of the path before a
/// space do not keep it.
#[test]
fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() {
let base = std::env::temp_dir().join(format!("vc-continuation-{}", std::process::id()));
let root = base.join("in");
std::fs::create_dir_all(&root).unwrap();
let hidden_path = base.join("in x").join("b.svg").to_string_lossy().into_owned();
let other = base.join("outside").join("b.svg").to_string_lossy().into_owned();
let known = format!("loaded/{other}{}sub.svg", std::path::MAIN_SEPARATOR);
let hidden = hiding(&[(&format!("loaded/{hidden_path}"), "loaded/b.svg"), (&format!("loaded/{other}"), "loaded/b.svg (2)")], &[&known]);
let up = ["", "..", "..", "in", "a.svg"].join(std::path::MAIN_SEPARATOR_STR);
let mut reply = json!({"error": format!("kept {hidden_path}.bak, {other}{up} and {known}")});
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden));
let _ = std::fs::remove_dir_all(base);
assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}");
// A readable library's id with a space after the hidden part stays whole.
// A readable id with a `..` part does not keep the hidden part.
let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg", "loaded//art/in/../ok/c.svg"]);
let mut reply = json!({"error": "x loaded//art/in work/a.svg, /art/in work/a.svg and /art/in work; /art/in/../ok/c.svg"});
hide_under_a_root("space-continuation", &mut reply, &hidden);
assert_eq!(reply["error"], "x loaded//art/in work/a.svg, /art/in work/a.svg and in work; in/../ok/c.svg", "{reply}");
}
/// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden
/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform.
#[test]
fn a_reply_hides_a_path_in_any_case_and_escaped() {
let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]);
let mut reply = json!({"error": "from c:/LIBS/my swatches/B.SVG and file:///C:/Libs/My%20Swatches/b.svg"});
let pairs = super::hidden_text(&hidden);
let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true };
super::replace_in_value(&mut reply, &rewrite);
assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}");
// Two libraries whose paths differ only in letter case keep their own ids.
let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]);
let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"});
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two), hiding: &two, fold: true });
assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}");
// A readable id that begins with a hidden one stays whole in any case, `loaded/` included.
let nested = hiding(&[("loaded//libs/in", "loaded/in")], &["loaded//libs/in/a.svg"]);
let mut reply = json!({"error": "LOADED//LIBS/IN/A.SVG"});
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested), hiding: &nested, fold: true });
assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}");
let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"});
hide_under_a_root("escaped", &mut reply, &hidden);
assert_eq!(reply["error"], "file:///b.svg", "{reply}");
let hidden = hiding(&[("loaded//libs/caf\u{e9}/b.svg", "loaded/b.svg")], &[]);
let mut reply = json!({"error": "file:///libs/caf%C3%a9/b.svg and file:///libs%2Fcaf%c3%A9%2Fb.svg?next=%2Fdocs&x=%FF at 100%25"});
hide_under_a_root("escaped-mixed", &mut reply, &hidden);
assert_eq!(reply["error"], "file://b.svg and file://b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}");
}
/// A path a failed request sent takes the place of a hidden library's id for the same path, written
/// with the other separator, and in another letter case when matching in any case (as on Windows).
#[test]
fn a_sent_path_replaces_a_hidden_id_for_the_same_path() {
let mut hidden = hiding(&[(r"loaded/C:\Libs\b.svg", "loaded/b.svg (2)"), ("loaded//other/b.svg", "loaded/b.svg")], &[]);
hidden.set_hidden(vec![("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())], true);
let shown: Vec<&str> = hidden.hidden.iter().map(|(_, shown)| shown.as_str()).collect();
assert_eq!(shown, ["loaded/B.svg", "loaded/b.svg"], "{hidden:?}");
assert_eq!(hidden.sent, [("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())]);
hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/x.svg".to_string())], false);
assert_eq!((hidden.hidden[1].1.as_str(), hidden.sent.len()), ("loaded/b.svg", 2), "{hidden:?}");
// A library loaded by a relative path, and the absolute path it reads as, sent: the sent path's
// file name is shown for both, not the library's numbered id.
let mut hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg (2)")], &[]);
let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned();
hidden.set_hidden(vec![(format!("loaded/{absolute}"), "loaded/icon.svg".to_string())], false);
let mut reply = json!({"error": format!("can't read \"data/icon.svg\" or {absolute}")});
hide_under_a_root("sent-absolute", &mut reply, &hidden);
assert_eq!(reply["error"], "can't read \"icon.svg\" or icon.svg", "{reply}");
}
/// Outside Windows, a `\` in a native path is part of a name: a sent path that differs from a hidden
/// library's path only by `/` in place of that `\` names another file, so the library keeps its id
/// and a reply does not read either path as a spelling of the other.
#[cfg(not(windows))]
#[test]
fn a_backslash_in_a_native_path_keeps_two_paths_apart() {
let mut hidden = hiding(&[(r"loaded//out/weird\name.svg", r"loaded/weird\name.svg")], &[]);
hidden.set_hidden(vec![("loaded//out/weird/name.svg".to_string(), "loaded/name.svg".to_string())], false);
assert_eq!(hidden.hidden[0].1, r"loaded/weird\name.svg", "{hidden:?}");
let mut reply = json!({"error": r"no /out/weird\name.svg, /out/weird/name.svg or \out\weird\name.svg"});
hide_under_a_root("native-backslash", &mut reply, &hidden);
assert_eq!(reply["error"], r"no weird\name.svg, name.svg or \out\weird\name.svg", "{reply}");
}
/// A sent path whose text overlaps part of a hidden library's path does not keep that library's path
/// from being hidden: the hidden libraries are matched first.
#[test]
fn a_sent_path_does_not_stop_a_hidden_library_path_from_being_hidden() {
let mut hidden = hiding(&[("loaded//home/secret/dir/lib", "loaded/lib")], &[]);
hidden.set_hidden(vec![("loaded//dir/lib/sent-folder/longer".to_string(), "loaded/longer".to_string())], false);
let mut reply = json!({"error": "can't read /home/secret/dir/lib/sent-folder/longer"});
hide_under_a_root("sent-overlap", &mut reply, &hidden);
assert_eq!(reply["error"], "can't read lib/sent-folder/longer", "{reply}");
}
/// A hidden relative path is also hidden where a reply shows it as the absolute path it reads as from
/// the working folder.
#[test]
fn a_reply_hides_a_hidden_relative_path_written_as_absolute() {
let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]);
let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned();
let mut reply = json!({"error": format!("can't read {absolute}")});
hide_under_a_root("relative-absolute", &mut reply, &hidden);
assert_eq!(reply["error"], "can't read icon.svg", "{reply}");
}
/// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names
/// another host), under `~` or starting with `../`, or as a relative path in a path param, are hidden
/// by their file names; plain text and a path inside the root are not.
#[test]
fn a_request_sends_paths_that_an_error_hides() {
let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id()));
let root = base.join("in");
std::fs::create_dir_all(&root).unwrap();
let inside = root.join("a.svg").to_string_lossy().into_owned();
let keep = json!(["a/b", inside, "file://host/g.svg"]);
let params = json!({"path": "/x/my lib/b.svg", "file": "outside/h.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep});
let mut sent = Vec::new();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&params, &mut sent));
let _ = std::fs::remove_dir_all(base);
let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}"));
let want = [
pair("outside/h.svg", "h.svg"),
pair("/y/c d.svg", "c d.svg"),
pair("~/e.svg", "e.svg"),
pair("../f.svg", "f.svg"),
pair("//host/g.svg", "g.svg"),
pair("/x/my lib/b.svg", "b.svg"),
];
assert_eq!(sent, want);
}
/// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a
/// relative name and a path inside the root are not.
#[test]
fn an_absolute_path_outside_the_read_root_is_unreadable() {
let base = std::env::temp_dir().join(format!("vc-unreadable-absolute-{}", std::process::id()));
std::fs::create_dir_all(base.join("in")).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
let inside = base.join("in").join("a.svg").to_string_lossy().into_owned();
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
assert!(super::unreadable_absolute("/outside/root/x"));
assert!(super::unreadable_absolute("file:///outside/root/x"));
assert!(super::unreadable_absolute("FILE:///outside/root/x"));
assert!(super::unreadable_absolute("file://other-host/share/x"));
let url = if inside.starts_with('/') { format!("File://{inside}") } else { format!("File:///{}", inside.replace('\\', "/")) };
assert!(!super::unreadable_absolute(&url), "{url}");
assert!(super::readable_value(&url), "{url}");
let escaped = url.replacen("/in/a.svg", "/%69n/a.svg", 1);
assert!(!super::unreadable_absolute(&escaped) && super::readable_value(&escaped), "{escaped}");
assert!(super::unreadable_absolute("file:///outside%2Froot/x"));
assert!(!super::unreadable_absolute(""));
assert!(!super::unreadable_absolute("keep"));
assert!(!super::unreadable_absolute(&inside));
});
let _ = std::fs::remove_dir_all(base);
}
/// Without a read root, a `file:` URL that names another host may be read, and a recorded step keeps
/// it.
#[test]
fn a_url_naming_another_host_stays_without_a_read_root() {
assert!(!super::unreadable_absolute("file://host/share/a.svg"));
assert!(super::readable_value("file://host/share/a.svg"));
let mut params = json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]});
let fields = params.as_object_mut().unwrap();
super::leave_out_unreadable_params(fields);
assert_eq!(params, json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]}));
}
/// A hidden path quoted with `{:?}` is hidden with its quotes and control characters escaped.
#[test]
fn a_reply_hides_a_quoted_path_with_escaped_quotes() {
let hidden = hiding(&[("loaded//outside/a\"b\t.svg", "loaded/a\"b\t.svg")], &[]);
let mut reply = json!({"error": format!("can't read {:?}", "/outside/a\"b\t.svg")});
hide_under_a_root("quoted", &mut reply, &hidden);
assert_eq!(reply["error"], "can't read \"a\\\"b\\t.svg\"", "{reply}");
}
/// Each replacement is chosen from the text as given: a hidden path inside the id shown for another
/// hidden library is not replaced again, and a readable id written with `%XX` escapes keeps the hidden
/// path it begins with.
#[test]
fn a_reply_replaces_each_hidden_text_once() {
let hidden = hiding(&[("loaded//b.svg", "loaded/b.svg")], &[]);
let mut reply = json!({"error": "no loaded//b.svg"});
hide_under_a_root("root-file", &mut reply, &hidden);
assert_eq!(reply["error"], "no loaded/b.svg", "{reply}");
let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg"]);
let mut reply = json!({"error": "file:///art/in%20work/a.svg"});
hide_under_a_root("escaped-readable", &mut reply, &hidden);
assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}");
}
/// A library saved with another platform's absolute path is hidden, also when the working folder is
/// inside the read root.
#[test]
fn a_library_saved_with_another_platforms_path_is_hidden() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string();
let here = std::env::current_dir().unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap();
let hiding = vectorcraft_engine::file_access::confine(roots.as_ref(), || super::unreadable_library_ids(&app));
assert_eq!(hiding.hidden, [(r"loaded/C:\Users\someone\b.svg".to_string(), "loaded/b.svg".to_string())], "{hiding:?}");
}
/// Another platform's absolute path is outside the read root, also when the working folder is inside
/// it: a request that sends one sees it by its file name.
#[test]
fn another_platforms_absolute_path_is_unreadable() {
let here = std::env::current_dir().unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap();
let path = r"C:\Users\someone\private.svg";
let mut sent = Vec::new();
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
assert!(!super::readable(path) && super::unreadable_absolute(path));
super::sent_paths(&json!({"list": [path]}), &mut sent);
});
assert_eq!(sent, [(format!("loaded/{path}"), "loaded/private.svg".to_string())]);
}
/// A hidden relative path is hidden where it stands alone or after leading `./` and `../` parts, and
/// stays where it ends a longer path, also one with a `.` or `..` part before it.
#[test]
fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() {
let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]);
let mut reply = json!({"result": "kept /work/data/icon.svg and xdata/icon.svg, hid ./data/icon.svg, \"data/icon.svg\" and loaded/data/icon.svg"});
hide_under_a_root("relative", &mut reply, &hidden);
assert_eq!(reply["result"], "kept /work/data/icon.svg and xdata/icon.svg, hid ./icon.svg, \"icon.svg\" and loaded/icon.svg", "{reply}");
let mut reply = json!({"result": "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../data/icon.svg and './../data/icon.svg'"});
hide_under_a_root("relative-dots", &mut reply, &hidden);
assert_eq!(
reply["result"], "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../icon.svg and './../icon.svg'",
"{reply}"
);
}
/// Outside Windows a `\` is part of a file name: a sent path outside the read root is shown by its
/// whole file name.
#[cfg(not(windows))]
#[test]
fn a_sent_path_keeps_a_backslash_in_its_file_name() {
let root = std::env::temp_dir().join(format!("vc-sent-backslash-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
let mut sent = Vec::new();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&json!({"path": "/out/weird\\name.svg"}), &mut sent));
let _ = std::fs::remove_dir_all(root);
assert_eq!(sent, [("loaded//out/weird\\name.svg".to_string(), "loaded/weird\\name.svg".to_string())]);
}
/// `ui.inspect` and `ui.docking` show a hidden library whose file is at the top of the file system
/// (`/b.svg`) as `loaded/b.svg`, with its separator.
#[test]
fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
let panel = "library:swatches:loaded//b.svg".to_string();
app.ui.color_guide_limit = "loaded//b.svg".to_string();
app.ui.open_panel = Some(panel);
let root = std::env::temp_dir().join(format!("vc-top-level-library-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
let (inspected, docking) = vectorcraft_engine::file_access::confine(roots.as_ref(), || {
(input_request(&mut app, "ui.inspect", json!({})), input_request(&mut app, "ui.docking", json!({})))
});
let _ = std::fs::remove_dir_all(root);
assert_eq!(inspected["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{inspected}");
assert_eq!(inspected["result"]["ui"]["open_panel"], "library:swatches:loaded/b.svg", "{inspected}");
assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}");
}
/// A path outside the read root that a request sent is shown by its file name in a reply that succeeded
/// as in one that failed.
#[test]
fn a_sent_path_reads_the_same_whatever_the_outcome() {
let app = VectorcraftApp::new(Session::new(), Services::default());
let root = std::env::temp_dir().join(format!("vc-sent-outcome-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
let sent = json!({"path": "/outside/b.svg"});
let mut done = json!({"ok": true, "result": {"path": "/outside/b.svg"}});
let mut failed = json!({"ok": false, "error": "can't read /outside/b.svg"});
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
super::hide_in_reply(&app, &mut done, &sent, super::Hiding::default());
super::hide_in_reply(&app, &mut failed, &sent, super::Hiding::default());
});
let _ = std::fs::remove_dir_all(root);
assert_eq!(done["result"]["path"], "b.svg", "{done}");
assert_eq!(failed["error"], "can't read b.svg", "{failed}");
}
#[test]
fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
+2
View File
@@ -18,6 +18,7 @@ pub mod blend_options;
mod brush_art_options;
pub mod brush_delete;
pub mod brush_options;
pub mod brush_replace_art;
pub mod bullets_numbering;
pub mod char_style_options;
pub mod color_balance;
@@ -322,6 +323,7 @@ registry! {
SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC,
Confirm: [confirm::KIND] => confirm::SPEC,
BrushDelete: [brush_delete::KIND] => brush_delete::SPEC,
BrushReplaceArt: [brush_replace_art::KIND] => brush_replace_art::SPEC,
Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC,
Replace: [confirm::REPLACE] => confirm::REPLACE_SPEC,
ResetPreferences: [confirm::RESET_PREFS] => confirm::RESET_PREFS_SPEC,
@@ -0,0 +1,100 @@
//! The Brushes panel's question when art Alt-dragged (Option-dragged on macOS) onto a brush that
//! strokes use is to replace its art ([`ask`]): Apply to Strokes, Leave Strokes or Cancel. Apply to
//! Strokes runs `brush.replaceArt` and the strokes redraw with the new art; Leave Strokes runs it
//! with `applyToStrokes: false`, which keeps the brush and its strokes as they were and adds a copy
//! of the brush with the new art.
//!
//! Fields: `name` (the brush), `ids` (the art), `apply` (set by the button pressed) and `__doc` (the
//! uid of the document it asks about).
use serde_json::{Value, json};
use super::{DialogAction, DialogSpec};
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::widgets;
/// The dialog kind of the question.
pub const KIND: &str = "brushReplaceArt";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| {
tl!("Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art?")
.to_string()
},
body_with_actions: Some(body),
confirm,
ok: None,
max_width: Some(540.0),
..DialogSpec::FORM
};
/// The field that holds the uid of the document the question is about.
const DOC: &str = "__doc";
/// Replace the art of brush `name` with the objects `ids`: at once when no stroke uses the brush,
/// else after asking whether the strokes take the new art. Art the brush cannot take (art with no
/// width, say) is refused before anything asks, with the reason in the status bar.
pub fn replace(app: &mut VectorcraftApp, name: &str, ids: &[u64]) {
let params = json!({"name": name, "ids": ids});
if super::brush_delete::used(app, &[name.to_string()]) != Some(true) {
if let Err(e) = app.run("brush.replaceArt", params) {
app.status(e);
}
return;
}
// A preview on a copy of the document checks the art without changing anything.
if !app.session.in_interaction() && app.session.begin_interaction("Replace Brush Art").is_ok() {
let checked = app.session.preview("brush.replaceArt", &params);
// The interaction began above, on the active document: ending it puts that document back
// as it was, and can fail only when no document is open, when there is nothing to put back.
let _ = app.session.cancel_interaction();
if let Err(e) = checked {
app.status(e.to_string());
return;
}
}
ask(app, name, ids);
}
/// Ask whether the strokes that use brush `name` take the art of the objects `ids`.
pub fn ask(app: &mut VectorcraftApp, name: &str, ids: &[u64]) {
let doc = app.session.active().map(|d| d.uid);
app.ui.dialog = Some(Dialog::new(KIND, json!({"name": name, "ids": ids, "apply": true, DOC: doc})));
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> DialogAction {
let mut action = DialogAction::None;
ui.horizontal(|ui| {
if widgets::primary_button_enabled(ui, tl!("Apply to Strokes"), true).clicked() {
d.fields.insert("apply".into(), json!(true));
action = DialogAction::Confirm;
}
if widgets::secondary_button(ui, tl!("Leave Strokes")).clicked() {
d.fields.insert("apply".into(), json!(false));
action = DialogAction::Confirm;
}
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
action = DialogAction::Cancel;
}
});
action
}
/// Closes before running. When another document has become the active one, it closes as Cancel does
/// and changes no brush.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
app.ui.dialog = None;
if d.fields.get(DOC).and_then(Value::as_u64) != app.session.active().map(|doc| doc.uid) {
return Ok(json!({}));
}
let params = json!({
"name": d.fields.get("name").cloned().unwrap_or(Value::Null),
"ids": d.fields.get("ids").cloned().unwrap_or_else(|| json!([])),
"applyToStrokes": d.bool("apply"),
});
app.run("brush.replaceArt", params)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,133 @@
use egui::{Pos2, Rect, pos2, vec2};
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_engine::Session;
use super::*;
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>, time: f64) -> egui::FullOutput {
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));
let input = egui::RawInput { screen_rect, time: Some(time), events, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
out
}
fn press(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, time: f64) {
frame(ctx, app, vec![], time);
let out = frame(ctx, app, vec![], time + 0.1);
let nodes = &out.platform_output.accesskit_update.as_ref().expect("dialog accessibility tree").nodes;
let button = nodes.iter().find(|(_, node)| node.label().map(str::trim) == Some(label));
let bounds = button.and_then(|(_, node)| node.bounds()).expect("visible button");
let at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32);
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true), button(false)], time + 0.2);
}
fn context() -> egui::Context {
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
ctx
}
fn rect(app: &mut VectorcraftApp, x: f64, y: f64, w: f64, h: f64) -> u64 {
app.run("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()
}
fn art_height(app: &mut VectorcraftApp, name: &str) -> f64 {
let art: vectorcraft_doc::Node = serde_json::from_value(app.run("brush.get", json!({"name": name})).unwrap()["art"].clone()).unwrap();
art.geometric_bounds().unwrap().height()
}
/// An art brush "Bar" (40 × 8 pt) painting a path, and a 40 × 20 pt rectangle: → (path, rectangle).
fn used_brush(app: &mut VectorcraftApp) -> (NodeId, u64) {
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let bar = rect(app, 10.0, 10.0, 40.0, 8.0);
app.run("brush.new", json!({"type": "art", "name": "Bar", "ids": [bar]})).unwrap();
let path = NodeId(rect(app, 100.0, 100.0, 80.0, 40.0));
app.run("brush.apply", json!({"name": "Bar", "ids": [path.0]})).unwrap();
(path, rect(app, 10.0, 40.0, 40.0, 20.0))
}
#[test]
fn an_unused_brush_takes_the_art_without_asking() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let bar = rect(&mut app, 10.0, 10.0, 40.0, 8.0);
app.run("brush.new", json!({"type": "art", "name": "Bar", "ids": [bar]})).unwrap();
let tall = rect(&mut app, 10.0, 40.0, 40.0, 20.0);
replace(&mut app, "Bar", &[tall]);
assert!(app.ui.dialog.is_none());
assert!((art_height(&mut app, "Bar") - 20.0).abs() < 1e-6);
}
#[test]
fn apply_to_strokes_gives_the_brush_the_new_art() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let (path, tall) = used_brush(&mut app);
replace(&mut app, "Bar", &[tall]);
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND), "strokes use Bar: it asks");
let ctx = context();
let before = app.session.active().unwrap().history.undo.len();
press(&ctx, &mut app, "Apply to Strokes", 0.0);
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1);
assert!((art_height(&mut app, "Bar") - 20.0).abs() < 1e-6);
let stroke = app.session.active().unwrap().doc.node(path).and_then(|n| n.appearance.stroke()).unwrap();
assert_eq!(stroke.brush.as_deref(), Some("Bar"));
}
#[test]
fn leave_strokes_adds_a_copy_and_cancel_changes_nothing() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let (path, tall) = used_brush(&mut app);
let ctx = context();
let before = app.session.active().unwrap().history.undo.len();
replace(&mut app, "Bar", &[tall]);
press(&ctx, &mut app, "Cancel", 0.0);
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.active().unwrap().history.undo.len(), before);
assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6);
replace(&mut app, "Bar", &[tall]);
press(&ctx, &mut app, "Leave Strokes", 1.0);
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1);
assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6, "Bar stays as it was");
assert!((art_height(&mut app, "Bar copy") - 20.0).abs() < 1e-6);
let stroke = app.session.active().unwrap().doc.node(path).and_then(|n| n.appearance.stroke()).unwrap();
assert_eq!(stroke.brush.as_deref(), Some("Bar"));
}
/// Art a used brush cannot take is refused before the question: no dialog, the reason in the status
/// bar, and the document and its undo history as they were.
#[test]
fn art_with_no_width_is_refused_before_it_asks() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let _ = used_brush(&mut app);
let flat = app.run("shape.line", json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap();
let st = app.session.active().unwrap();
let (doc, steps) = (st.doc.clone(), st.history.undo.len());
replace(&mut app, "Bar", &[flat]);
assert!(app.ui.dialog.is_none(), "nothing asks");
assert!(app.ui.status.contains("the selected art has no width"), "{}", app.ui.status);
let st = app.session.active().unwrap();
assert!(st.interaction.is_none());
assert_eq!(*st.doc, *doc);
assert_eq!(st.history.undo.len(), steps);
}
#[test]
fn a_button_changes_no_brush_after_another_document_became_active() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let (_, tall) = used_brush(&mut app);
let ctx = context();
replace(&mut app, "Bar", &[tall]);
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("document.activate", json!({"index": 0})).unwrap();
app.run("document.activate", json!({"index": 1})).unwrap();
press(&ctx, &mut app, "Apply to Strokes", 0.0);
assert!(app.ui.dialog.is_none());
app.run("document.activate", json!({"index": 0})).unwrap();
assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6);
}
+13 -5
View File
@@ -382,25 +382,28 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
/// The parameters whose dialogs list their values in their own bodies, as (label, value), each
/// label as that dialog passes it to its dropdown or radio buttons, which translate it into the UI
/// language: Revolve's edge, the bevel of both Extrude & Bevels (Rounded translated in the "3d"
/// context first), Extrude & Bevel (Classic)'s surface and shading color, every Warp style's
/// language: the edge of both Revolves, the bevel of both Extrude & Bevels (Rounded translated in
/// the "3d" context first), the surface and shading color of the classic 3D effects, every Warp style's
/// orientation, Rasterize's Color Model (its `document` value labeled
/// CMYK when `cmyk`, for a document in CMYK, and RGB otherwise), Background and Anti-aliasing, and
/// the channel of Levels and Curves.
pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&'static str, &'static str)>> {
let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s);
Some(match (effect, key) {
(vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")],
(vectorcraft_effects::REVOLVE | vectorcraft_effects::CLASSIC_REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")],
(
vectorcraft_effects::REVOLVE | vectorcraft_effects::EXTRUDE | vectorcraft_effects::INFLATE | vectorcraft_effects::ROTATE,
"shadowPosition",
) => {
vec![(tl!("Behind Object"), "behind"), (tl!("Below Object"), "below")]
}
(vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => {
(vectorcraft_effects::CLASSIC_EXTRUDE | vectorcraft_effects::CLASSIC_REVOLVE, "surface") => {
vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")]
}
(vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor") => vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")],
(vectorcraft_effects::CLASSIC_ROTATE, "surface") => vec![(tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse")],
(vectorcraft_effects::CLASSIC_EXTRUDE | vectorcraft_effects::CLASSIC_REVOLVE | vectorcraft_effects::CLASSIC_ROTATE, "shadingColor") => {
vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")]
}
(vectorcraft_effects::EXTRUDE | vectorcraft_effects::CLASSIC_EXTRUDE, "bevel") => vec![
(tl!("None"), "none"),
(tl!("Classic"), "classic"),
@@ -965,6 +968,11 @@ mod tests {
(vectorcraft_effects::CLASSIC_EXTRUDE, "bevel"),
(vectorcraft_effects::CLASSIC_EXTRUDE, "surface"),
(vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor"),
(vectorcraft_effects::CLASSIC_REVOLVE, "edge"),
(vectorcraft_effects::CLASSIC_REVOLVE, "surface"),
(vectorcraft_effects::CLASSIC_REVOLVE, "shadingColor"),
(vectorcraft_effects::CLASSIC_ROTATE, "surface"),
(vectorcraft_effects::CLASSIC_ROTATE, "shadingColor"),
("warp.arc", "orientation"),
("warp.twist", "orientation"),
(vectorcraft_effects::RASTERIZE, "background"),
@@ -40,6 +40,7 @@ pub fn open(app: &mut VectorcraftApp, para: bool, target: Value) -> Result<Value
fields.insert("section".into(), json!("general"));
let attrs = target.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
fields.extend(super::para_style_options::form_fields(&attrs, app.session.general_unit()));
fields.insert("__vertical".into(), json!(super::para_style_options::vertical_text(app)));
} else {
fields.insert("section".into(), json!("general"));
fields.insert("__smallCapsSize".into(), json!(d.setup.small_caps_size));

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