mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 23:31:23 +00:00
Merge main into fix/ai-writer-object-limit-2
This commit is contained in:
Generated
+11
@@ -6264,6 +6264,7 @@ dependencies = [
|
||||
"vectorcraft-eps",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-js",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pdf",
|
||||
@@ -6332,6 +6333,14 @@ dependencies = [
|
||||
"vectorcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-js"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"unicode-general-category",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.21.0"
|
||||
@@ -6413,8 +6422,10 @@ dependencies = [
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
|
||||
@@ -53,6 +53,7 @@ vectorcraft-metafile = { path = "crates/metafile" }
|
||||
vectorcraft-plugins = { path = "crates/plugins" }
|
||||
vectorcraft-affinity = { path = "crates/affinity" }
|
||||
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
|
||||
vectorcraft-js = { path = "crates/js" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
|
||||
@@ -231,6 +231,21 @@ impl GenericCmyk {
|
||||
}
|
||||
g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::<f32>();
|
||||
}
|
||||
// An ink at a bound whose descent direction points out of the box stays where it is; the step moves the other inks.
|
||||
for p in 0..3 {
|
||||
if (x[p] <= 0.0 && g[p] < 0.0) || (x[p] >= 1.0 && g[p] > 0.0) {
|
||||
a[p] = [0.0; 3];
|
||||
for row in &mut a {
|
||||
row[p] = 0.0;
|
||||
}
|
||||
a[p][p] = 1.0;
|
||||
g[p] = 0.0;
|
||||
}
|
||||
}
|
||||
// Every ink is held at a bound, or the gradient is zero: no step can lower the error.
|
||||
if g == [0.0; 3] {
|
||||
break;
|
||||
}
|
||||
let mut improved = false;
|
||||
for _ in 0..8 {
|
||||
let mut al = a;
|
||||
|
||||
@@ -259,6 +259,46 @@ fn generic_cmyk_black_round_trips_with_bpc() {
|
||||
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
|
||||
}
|
||||
|
||||
/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few
|
||||
/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024
|
||||
/// in all) stays within 0.001 on every ink.
|
||||
#[test]
|
||||
fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() {
|
||||
let c = cms();
|
||||
let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0];
|
||||
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
|
||||
for step in -2000i32..=2000 {
|
||||
let mut rgb = pink;
|
||||
rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step));
|
||||
let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
|
||||
for i in 0..4 {
|
||||
lo[i] = lo[i].min(ink[i]);
|
||||
hi[i] = hi[i].max(ink[i]);
|
||||
}
|
||||
}
|
||||
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
|
||||
assert_eq!(lo[0], 0.0, "{lo:?}");
|
||||
}
|
||||
|
||||
/// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change
|
||||
/// by a few float steps: sRGB (0, 0, 0.25) with its blue stepped one `f32` at a time over 121 values
|
||||
/// stays within 0.001 on every ink.
|
||||
#[test]
|
||||
fn generic_cmyk_separates_nearby_colors_with_inks_at_full_alike() {
|
||||
let c = cms();
|
||||
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
|
||||
for step in -60i32..=60 {
|
||||
let blue = f32::from_bits(0.25f32.to_bits().wrapping_add_signed(step));
|
||||
let ink = c.srgb_to_cmyk([0.0, 0.0, blue], Intent::RelativeColorimetric);
|
||||
for i in 0..4 {
|
||||
lo[i] = lo[i].min(ink[i]);
|
||||
hi[i] = hi[i].max(ink[i]);
|
||||
}
|
||||
}
|
||||
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
|
||||
assert_eq!((lo[0], lo[1]), (1.0, 1.0), "{lo:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_colours_round_trip_through_the_cms() {
|
||||
let c = cms_no_bpc();
|
||||
|
||||
@@ -53,8 +53,9 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation,
|
||||
#[cfg(test)]
|
||||
use blend_spec::{blend_color_shift, blend_ramp};
|
||||
pub use envelope::{
|
||||
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_content_boxes,
|
||||
envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, smooth_handles,
|
||||
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds,
|
||||
envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin,
|
||||
set_type_ink, smooth_handles,
|
||||
};
|
||||
pub(crate) use expand::max_outset;
|
||||
pub use expand::{
|
||||
|
||||
+394
-17
@@ -1,6 +1,7 @@
|
||||
use super::expand::{Hooks, Outliner, Warper, map_node};
|
||||
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of};
|
||||
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of};
|
||||
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
|
||||
use vectorcraft_geom::Homography;
|
||||
|
||||
/// How an envelope distorts its content.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -127,19 +128,293 @@ impl EnvelopeOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of `content` in the axes of `frame`, with its strokes and geometric. Strokes scale
|
||||
/// with the frame as [`Node::transform`] scales them.
|
||||
/// The bounds of `content` in the axes of `frame`, with its strokes and geometric, type measured by
|
||||
/// its glyph outlines (see [`envelope_content_bounds`]). Strokes scale with the frame as
|
||||
/// [`Node::transform`] scales them.
|
||||
pub fn envelope_content_boxes(content: &[Arc<Node>], frame: Affine) -> Option<(Rect, Rect)> {
|
||||
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
|
||||
let to_frame = frame.inverse();
|
||||
let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| {
|
||||
let mut m = (**c).clone();
|
||||
m.transform(to_frame, true);
|
||||
(vectorcraft_geom::union_opt(stroked, m.visual_bounds()), vectorcraft_geom::union_opt(plain, m.geometric_bounds()))
|
||||
(vectorcraft_geom::union_opt(stroked, content_box(c, true, to_frame)), vectorcraft_geom::union_opt(plain, content_box(c, false, to_frame)))
|
||||
});
|
||||
Some((stroked?, plain?))
|
||||
}
|
||||
|
||||
/// The geometric bounds an envelope around `content` on the page axes maps from: type, alone or
|
||||
/// in groups and layers, is measured by its glyph outlines, underlines and strikethroughs instead
|
||||
/// of its laid-out box. Type keeps its laid-out box while nothing is registered with
|
||||
/// [`set_type_ink`], and when it draws nothing.
|
||||
pub fn envelope_content_bounds(content: &[Arc<Node>]) -> Option<Rect> {
|
||||
content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, Affine::IDENTITY)))
|
||||
}
|
||||
|
||||
/// The box of type's glyph outlines, underlines and strikethroughs with its text space mapped by
|
||||
/// the given transform (outlines mapped point by point) and, for type in perspective, then drawn
|
||||
/// through its projection and mapped by the second transform. `None` when it draws nothing. Type is
|
||||
/// laid out above this crate, so the text crate registers one with [`set_type_ink`].
|
||||
pub type TypeInk = fn(&crate::TextObject, Affine, Option<(Homography, Affine)>) -> Option<Rect>;
|
||||
|
||||
/// What [`TypeInk`] maps type's text space by: a transform, and a projection with the transform
|
||||
/// after it.
|
||||
type TypeMap = (Affine, Option<(Homography, Affine)>);
|
||||
|
||||
static TYPE_INK: std::sync::OnceLock<(TypeInk, crate::hit::LiveInputs)> = std::sync::OnceLock::new();
|
||||
|
||||
/// Register how envelopes measure type, and a number that changes whenever what it reads besides
|
||||
/// the type changes, such as the installed fonts (the first registration stays).
|
||||
pub fn set_type_ink(f: TypeInk, inputs: crate::hit::LiveInputs) {
|
||||
// Already set: the same measure, registered by another session, an import or a renderer.
|
||||
let _ = TYPE_INK.set((f, inputs));
|
||||
}
|
||||
|
||||
/// How many entries each table of [`TypeInks`] holds before it is emptied.
|
||||
const TYPE_INK_KEPT: usize = 1024;
|
||||
|
||||
/// `map`, bit for bit, for a table key.
|
||||
fn map_bits(map: &TypeMap) -> [u64; 21] {
|
||||
let mut bits = [0; 21];
|
||||
let (m, projection) = map;
|
||||
for (b, v) in bits.iter_mut().zip(m.as_coeffs()) {
|
||||
*b = v.to_bits();
|
||||
}
|
||||
if let Some((h, after)) = projection {
|
||||
for (b, v) in bits[6..].iter_mut().zip(h.to_array().into_iter().chain(after.as_coeffs())) {
|
||||
*b = v.to_bits();
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
/// A key of [`TypeInks::nodes`]: the object's address, the map its outlines are measured in (an
|
||||
/// affine map without its move) and the inputs number registered with [`set_type_ink`].
|
||||
type NodeKey = (usize, [u64; 21], u64);
|
||||
|
||||
/// A key of [`TypeInks::content`]: two 64-bit hashes of the type without its transform's move and
|
||||
/// its layout caches ([`content_hash`]), the map its outlines are measured in, and the inputs number.
|
||||
type ContentKey = ([u64; 2], [u64; 21], u64);
|
||||
|
||||
/// Type measured by [`type_ink`] on one thread, by the map it is measured in and the inputs
|
||||
/// number: by the object (an unchanged object is found without being read),
|
||||
/// and by its content (a moved or copied object is found without laying it out again). Neither
|
||||
/// table keeps a copy of the type.
|
||||
#[derive(Default)]
|
||||
struct TypeInks {
|
||||
/// By the object's address, with a weak pointer that keeps the address from being reused.
|
||||
nodes: std::collections::HashMap<NodeKey, (std::sync::Weak<Node>, Option<Rect>)>,
|
||||
/// By the type's content.
|
||||
content: std::collections::HashMap<ContentKey, Option<Rect>>,
|
||||
}
|
||||
|
||||
/// Where [`TypeInks::find`] did not find type: its keys in both tables (no content key for type
|
||||
/// that could not be hashed whole).
|
||||
struct Missing {
|
||||
by_node: NodeKey,
|
||||
content: Option<ContentKey>,
|
||||
}
|
||||
|
||||
impl TypeInks {
|
||||
/// The kept box of type node `n` (holding `t`) mapped by `map`, or the keys to keep it under
|
||||
/// once it is measured.
|
||||
fn find(&mut self, n: &Arc<Node>, t: &crate::TextObject, map: &TypeMap, inputs: u64) -> Result<Option<Rect>, Box<Missing>> {
|
||||
// While the table holds a weak pointer to `n`'s allocation, no other object can be at that
|
||||
// address, and `n` can't be changed in place (`Arc::get_mut` refuses it and `Arc::make_mut`
|
||||
// moves it).
|
||||
let by_node = (Arc::as_ptr(n) as usize, map_bits(map), inputs);
|
||||
if let Some((_, ink)) = self.nodes.get(&by_node) {
|
||||
return Ok(*ink);
|
||||
}
|
||||
// Shrink Text to Fit lays type out by its size on the page, so only a move keeps its layout.
|
||||
let [a, b, c, d, _, _] = t.xf.as_coeffs();
|
||||
let mut text = t.clone();
|
||||
text.xf = Affine::new([a, b, c, d, 0.0, 0.0]);
|
||||
text.cached_bounds = None;
|
||||
text.cached_baselines.clear();
|
||||
let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs));
|
||||
match content.and_then(|key| self.content.get(&key)) {
|
||||
Some(ink) => {
|
||||
let ink = *ink;
|
||||
self.keep_node(n, by_node, ink);
|
||||
Ok(ink)
|
||||
}
|
||||
None => Err(Box::new(Missing { by_node, content })),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the box `ink` measured for type node `n` under the keys `missing`.
|
||||
fn keep(&mut self, n: &Arc<Node>, missing: Missing, ink: Option<Rect>) {
|
||||
if let Some(key) = missing.content {
|
||||
if self.content.len() >= TYPE_INK_KEPT {
|
||||
self.content.clear();
|
||||
}
|
||||
self.content.insert(key, ink);
|
||||
}
|
||||
self.keep_node(n, missing.by_node, ink);
|
||||
}
|
||||
|
||||
fn keep_node(&mut self, n: &Arc<Node>, by_node: NodeKey, ink: Option<Rect>) {
|
||||
if self.nodes.len() >= TYPE_INK_KEPT {
|
||||
self.nodes.retain(|_, (weak, _)| weak.strong_count() > 0);
|
||||
if self.nodes.len() >= TYPE_INK_KEPT {
|
||||
self.nodes.clear();
|
||||
}
|
||||
}
|
||||
self.nodes.insert(by_node, (Arc::downgrade(n), ink));
|
||||
}
|
||||
}
|
||||
|
||||
/// Two independent 64-bit hashes of `text` as saved (the layout caches are not saved), with what
|
||||
/// layout reads that is not saved: whether its first paragraph continues a thread and the bounds
|
||||
/// of its inline graphics. `None` when it can't be written whole.
|
||||
fn content_hash(text: &crate::TextObject) -> Option<[u64; 2]> {
|
||||
use std::hash::Hasher;
|
||||
/// Feeds what is written to two hashers.
|
||||
struct Feed([std::collections::hash_map::DefaultHasher; 2]);
|
||||
impl std::io::Write for Feed {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
for h in &mut self.0 {
|
||||
h.write(buf);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut feed = Feed(Default::default());
|
||||
// The second hasher starts from another state.
|
||||
feed.0[1].write_u8(1);
|
||||
serde_json::to_writer(&mut feed, text).ok()?;
|
||||
let inline_bounds = text.runs.iter().filter_map(|r| r.inline.as_ref()).map(|i| i.bounds.map(|b| [b.x0, b.y0, b.x1, b.y1].map(f64::to_bits)));
|
||||
for h in &mut feed.0 {
|
||||
h.write_u8(u8::from(text.first_para_continues));
|
||||
for b in inline_bounds.clone() {
|
||||
match b {
|
||||
None => h.write_u8(0),
|
||||
Some(bits) => {
|
||||
h.write_u8(1);
|
||||
bits.into_iter().for_each(|v| h.write_u64(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, b] = feed.0;
|
||||
Some([a.finish(), b.finish()])
|
||||
}
|
||||
|
||||
std::thread_local! {
|
||||
/// The type [`type_ink`] measured on this thread.
|
||||
static TYPE_INKS: std::cell::RefCell<TypeInks> = std::cell::RefCell::new(TypeInks::default());
|
||||
}
|
||||
|
||||
/// The box of the glyph outlines of type node `n` (holding `t`) with its text space mapped by `m`
|
||||
/// ([`set_type_ink`]) and, for type in perspective, drawn through `projection` and mapped by its
|
||||
/// second transform; measured again when `t`, the maps (without a move of `m` when there is no
|
||||
/// projection) or the registered inputs number change. `None` while nothing is registered.
|
||||
fn type_ink(n: &Arc<Node>, t: &crate::TextObject, m: Affine, projection: Option<(Homography, Affine)>) -> Option<Option<Rect>> {
|
||||
let (f, inputs) = TYPE_INK.get()?;
|
||||
let inputs = inputs();
|
||||
// Without a projection the type is measured without the move, which is added to the box.
|
||||
let [a, b, c, d, e, g] = m.as_coeffs();
|
||||
let (map, offset) = match projection {
|
||||
None => ((Affine::new([a, b, c, d, 0.0, 0.0]), None), Vec2::new(e, g)),
|
||||
Some(p) => ((m, Some(p)), Vec2::ZERO),
|
||||
};
|
||||
let ink = match TYPE_INKS.with_borrow_mut(|kept| kept.find(n, t, &map, inputs)) {
|
||||
Ok(ink) => ink,
|
||||
Err(missing) => {
|
||||
// Measured outside the borrow: laying type out may measure other type.
|
||||
let ink = f(t, map.0, map.1);
|
||||
TYPE_INKS.with_borrow_mut(|kept| kept.keep(n, *missing, ink));
|
||||
ink
|
||||
}
|
||||
};
|
||||
Some(ink.map(|r| r + offset))
|
||||
}
|
||||
|
||||
/// Add to `typed` the address of `n` and of each group and layer in it that holds type, which
|
||||
/// [`content_box`] measures by its glyphs; is there type in `n`? One walk of the tree.
|
||||
fn find_type(n: &Node, typed: &mut std::collections::HashSet<usize>) -> bool {
|
||||
let holds = match &n.kind {
|
||||
NodeKind::Text(_) => true,
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().fold(false, |holds, c| find_type(c, typed) | holds),
|
||||
_ => false,
|
||||
};
|
||||
if holds {
|
||||
typed.insert(n as *const Node as usize);
|
||||
}
|
||||
holds
|
||||
}
|
||||
|
||||
/// The box of `n` an envelope maps from, in the axes `to` takes the page to: its geometric bounds,
|
||||
/// or with `stroked` its visual bounds (strokes scaled by `to` as [`Node::transform`] scales them),
|
||||
/// with type measured by its glyph outlines ([`type_ink`]) instead of its laid-out box, in groups
|
||||
/// and layers too. Type is laid out as it is on the page, and its outlines are then taken into
|
||||
/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box.
|
||||
fn content_box(n: &Arc<Node>, stroked: bool, to: Affine) -> Option<Rect> {
|
||||
let mut typed = std::collections::HashSet::new();
|
||||
find_type(n, &mut typed);
|
||||
content_box_in(n, stroked, to, &typed)
|
||||
}
|
||||
|
||||
/// [`content_box`], with the addresses of the objects in `n` that hold type ([`find_type`]).
|
||||
fn content_box_in(n: &Arc<Node>, stroked: bool, to: Affine, typed: &std::collections::HashSet<usize>) -> Option<Rect> {
|
||||
// `n` in the axes of `to`.
|
||||
let moved = || {
|
||||
if to == Affine::IDENTITY {
|
||||
return std::borrow::Cow::Borrowed(&**n);
|
||||
}
|
||||
let mut m = (**n).clone();
|
||||
m.transform(to, true);
|
||||
std::borrow::Cow::Owned(m)
|
||||
};
|
||||
let own = |m: &Node| if stroked { m.visual_bounds() } else { m.geometric_bounds() };
|
||||
if !typed.contains(&(Arc::as_ptr(n) as usize)) {
|
||||
return own(&moved());
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
// Type in perspective: its outlines as its projection draws them.
|
||||
let ink = match n.projection() {
|
||||
None => type_ink(n, t, to * t.xf, None),
|
||||
Some(h) => type_ink(n, t, t.xf, Some((h, to))),
|
||||
}
|
||||
.flatten();
|
||||
let m = moved();
|
||||
let Some(ink) = ink else { return own(&m) };
|
||||
if !stroked {
|
||||
return Some(ink);
|
||||
}
|
||||
let (Some(v), Some(g)) = (m.visual_bounds(), m.geometric_bounds()) else { return Some(ink) };
|
||||
Some(Rect::new(
|
||||
ink.x0 - (g.x0 - v.x0).max(0.0),
|
||||
ink.y0 - (g.y0 - v.y0).max(0.0),
|
||||
ink.x1 + (v.x1 - g.x1).max(0.0),
|
||||
ink.y1 + (v.y1 - g.y1).max(0.0),
|
||||
))
|
||||
}
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
// A clipping path that paints strokes over the clip edge, as in `Node::visual_bounds`.
|
||||
let clip = children.first()?;
|
||||
content_box_in(clip, stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), to, typed)
|
||||
}
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
|
||||
let layer = matches!(n.kind, NodeKind::Layer { .. });
|
||||
let members = n.editing_children().iter();
|
||||
if stroked {
|
||||
// The container's own strokes, scaled by `to` as `Node::transform` scales them,
|
||||
// without copying its members.
|
||||
let mut own_strokes = Node::new(n.id, NodeKind::Group { children: Vec::new(), clip: false });
|
||||
own_strokes.appearance = n.appearance.clone();
|
||||
own_strokes.transform(to, true);
|
||||
own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed)))
|
||||
} else {
|
||||
members.filter(|c| c.visible || !layer).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed)))
|
||||
}
|
||||
}
|
||||
_ => own(&moved()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`EnvelopeOptions::bounds_margin`] of content with bounds `stroked` and `plain`. `None` for
|
||||
/// content with no width or no height, or for margins an `f32` can't hold.
|
||||
pub fn envelope_stroke_margin(stroked: Rect, plain: Rect) -> Option<[f32; 4]> {
|
||||
@@ -265,20 +540,13 @@ pub struct EnvelopeMap<'a> {
|
||||
}
|
||||
|
||||
impl<'a> EnvelopeMap<'a> {
|
||||
/// The map of an envelope of `kind` around `content` with `frame` and `options`. `None` when
|
||||
/// the content has no bounds.
|
||||
/// The map of an envelope of `kind` around `content` with `frame` and `options`, from the
|
||||
/// content's bounds with type measured by its glyph outlines ([`envelope_content_bounds`]).
|
||||
/// `None` when the content has no bounds.
|
||||
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option<Self> {
|
||||
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
|
||||
let to_frame = frame.inverse();
|
||||
let src = if frame == Affine::IDENTITY {
|
||||
nodes_bounds(content)?
|
||||
} else {
|
||||
content.iter().try_fold(None, |acc, c| {
|
||||
let mut m = (**c).clone();
|
||||
m.transform(to_frame, false);
|
||||
Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds()))
|
||||
})??
|
||||
};
|
||||
let src = content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, to_frame)))?;
|
||||
let src = options.source_box(src);
|
||||
Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) })
|
||||
}
|
||||
@@ -378,3 +646,112 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
|
||||
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{CharStyle, TextObject};
|
||||
|
||||
/// Look `n` up in `kept`, measuring it (and counting the measures) when it isn't there.
|
||||
fn look_up(kept: &mut TypeInks, n: &Arc<Node>, linear: Affine, measured: &mut usize) -> Option<Rect> {
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("type") };
|
||||
match kept.find(n, t, &(linear, None), 0) {
|
||||
Ok(ink) => ink,
|
||||
Err(missing) => {
|
||||
*measured += 1;
|
||||
let ink = Some(Rect::new(0.0, 0.0, *measured as f64, 1.0));
|
||||
kept.keep(n, *missing, ink);
|
||||
ink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn type_node(i: usize, text: &str) -> Arc<Node> {
|
||||
let t = TextObject::point(Point::new(i as f64 * 10.0, 0.0), text, CharStyle::default());
|
||||
Arc::new(Node::new(NodeId(i as u64), NodeKind::Text(Box::new(t))))
|
||||
}
|
||||
|
||||
/// Many type objects are each measured once however often they are looked up; a moved or copied
|
||||
/// object is found by its content, and a changed one or another transform is measured again.
|
||||
#[test]
|
||||
fn measured_type_is_kept_for_many_objects() {
|
||||
let mut kept = TypeInks::default();
|
||||
let mut measured = 0;
|
||||
let nodes: Vec<_> = (0..200).map(|i| type_node(i, &format!("Type {i}"))).collect();
|
||||
for _ in 0..5 {
|
||||
for n in &nodes {
|
||||
look_up(&mut kept, n, Affine::IDENTITY, &mut measured);
|
||||
}
|
||||
}
|
||||
assert_eq!(measured, 200);
|
||||
let mut moved = (*nodes[7]).clone();
|
||||
moved.transform(Affine::translate((300.0, 40.0)), true);
|
||||
let moved = Arc::new(moved);
|
||||
assert_eq!(look_up(&mut kept, &moved, Affine::IDENTITY, &mut measured), look_up(&mut kept, &nodes[7], Affine::IDENTITY, &mut measured));
|
||||
assert_eq!(measured, 200, "a moved object is found by its content");
|
||||
let changed = type_node(7, "Changed");
|
||||
look_up(&mut kept, &changed, Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 201, "a changed object is measured again");
|
||||
look_up(&mut kept, &nodes[7], Affine::rotate(0.5), &mut measured);
|
||||
assert_eq!(measured, 202, "another transform is measured again");
|
||||
}
|
||||
|
||||
/// Type that differs only in what layout reads but files don't save (a first paragraph that
|
||||
/// continues a thread, the resolved bounds of an inline graphic) is measured again.
|
||||
#[test]
|
||||
fn type_that_differs_in_what_is_not_saved_is_measured_again() {
|
||||
let mut kept = TypeInks::default();
|
||||
let mut measured = 0;
|
||||
let mut t = TextObject::point(Point::ZERO, "Type", CharStyle::default());
|
||||
t.runs.push(crate::TextRun::new("", CharStyle::default()));
|
||||
t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None });
|
||||
let node = |t: &TextObject| Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 1, "a copy is found by its content");
|
||||
t.first_para_continues = true;
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 2, "a first paragraph that continues a thread");
|
||||
if let Some(inline) = &mut t.runs[1].inline {
|
||||
inline.bounds = Some(Rect::new(0.0, 0.0, 10.0, 20.0));
|
||||
}
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 3, "an inline graphic's bounds");
|
||||
}
|
||||
|
||||
/// A stroked group of type in a scaled frame reaches past its members by its own strokes scaled
|
||||
/// with the frame, as the group moved into the frame does; deeply nested groups are measured in
|
||||
/// one walk each.
|
||||
#[test]
|
||||
fn a_stroked_group_of_type_is_measured_with_its_strokes_in_the_frame() {
|
||||
let text = type_node(1, "Group");
|
||||
let mut group = Node::group(NodeId(2), vec![text.clone()]);
|
||||
group.appearance = crate::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::solid(Color::BLACK), 4.0).into();
|
||||
let group = Arc::new(group);
|
||||
let to = Affine::scale(2.0);
|
||||
let mut moved = (*group).clone();
|
||||
moved.transform(to, true);
|
||||
let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to)));
|
||||
assert!(want.is_some());
|
||||
assert_eq!(content_box(&group, true, to), want);
|
||||
let mut nested = group;
|
||||
for i in 0..300 {
|
||||
nested = Arc::new(Node::group(NodeId(10 + i), vec![nested]));
|
||||
}
|
||||
assert!(content_box(&nested, false, Affine::IDENTITY).is_some());
|
||||
}
|
||||
|
||||
/// A table that fills up is emptied instead of growing, and what is looked up after that is
|
||||
/// measured again.
|
||||
#[test]
|
||||
fn the_tables_stay_bounded() {
|
||||
let mut kept = TypeInks::default();
|
||||
let mut measured = 0;
|
||||
let nodes: Vec<_> = (0..TYPE_INK_KEPT + 10).map(|i| type_node(i, &format!("Type {i}"))).collect();
|
||||
for n in &nodes {
|
||||
look_up(&mut kept, n, Affine::IDENTITY, &mut measured);
|
||||
}
|
||||
assert!(kept.content.len() <= TYPE_INK_KEPT && kept.nodes.len() <= TYPE_INK_KEPT, "{} {}", kept.content.len(), kept.nodes.len());
|
||||
assert_eq!(measured, TYPE_INK_KEPT + 10);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! those links (and colours) wherever art and swatches hold them, copying only the nodes that
|
||||
//! change.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
@@ -85,6 +86,12 @@ impl Document {
|
||||
fn numbered_swatch_name(&self, base: &str, from: usize) -> String {
|
||||
(from..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
/// Names for several new swatches: [`FreeSwatchNames::take`] gives what
|
||||
/// [`Document::free_swatch_name`] gives once the swatches named before it are added.
|
||||
pub fn free_swatch_names(&self) -> FreeSwatchNames {
|
||||
let taken = self.swatches_iter().map(|s| s.name.clone()).chain(self.swatch_groups.iter().map(|g| g.name.clone()));
|
||||
FreeSwatchNames { taken: taken.chain([REGISTRATION.to_string()]).collect(), next: HashMap::new() }
|
||||
}
|
||||
/// The name a new swatch of `paint` gets by default, unused in the document: a colour's values
|
||||
/// ([`color_name`], made free like [`Document::free_swatch_name`]), or "New Gradient Swatch 1",
|
||||
/// "New Pattern Swatch 1"…
|
||||
@@ -200,6 +207,52 @@ pub fn color_name(c: Color) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The swatch and color group names of a document ([`Document::free_swatch_names`]) and the names
|
||||
/// handed out since, for naming many new swatches without searching the document for each name.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FreeSwatchNames {
|
||||
taken: HashSet<String>,
|
||||
/// For each base and first number already numbered, the number its next numbered name is
|
||||
/// looked for from: names are only ever added, so the numbers below it stay taken.
|
||||
next: HashMap<(String, usize), usize>,
|
||||
}
|
||||
|
||||
impl FreeSwatchNames {
|
||||
/// `base`, or the first of "base 2", "base 3"… not taken, as [`Document::free_swatch_name`]
|
||||
/// gives it; the name is taken from then on.
|
||||
pub fn take(&mut self, base: &str) -> String {
|
||||
let name = if self.taken.contains(base) { self.numbered(base, 2) } else { base.to_string() };
|
||||
self.taken.insert(name.clone());
|
||||
name
|
||||
}
|
||||
/// The name [`Document::new_swatch_name`] gives a new swatch of `paint`; the name is taken
|
||||
/// from then on.
|
||||
pub fn take_new(&mut self, paint: &Paint) -> String {
|
||||
let base = match paint {
|
||||
Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => return self.take(&format!("{n} {}%", tint_percent(*tint))),
|
||||
Paint::Solid { color, .. } => return self.take(&color_name(*color)),
|
||||
Paint::Gradient(_) => "New Gradient Swatch",
|
||||
_ => "New Pattern Swatch",
|
||||
};
|
||||
let name = self.numbered(base, 1);
|
||||
self.taken.insert(name.clone());
|
||||
name
|
||||
}
|
||||
/// Count `name` as taken (a swatch or color group added another way).
|
||||
pub fn insert(&mut self, name: &str) {
|
||||
self.taken.insert(name.to_string());
|
||||
}
|
||||
/// The first of "base `from`", "base `from + 1`"… not taken.
|
||||
fn numbered(&mut self, base: &str, from: usize) -> String {
|
||||
let key = (base.to_string(), from);
|
||||
let start = self.next.get(&key).copied().unwrap_or(from);
|
||||
let (i, name) =
|
||||
(start..).map(|i| (i, format!("{base} {i}"))).find(|(_, n)| !self.taken.contains(n)).unwrap_or_else(|| (start, base.to_string()));
|
||||
self.next.insert(key, i + 1);
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`,
|
||||
/// true when it changed them.
|
||||
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
|
||||
@@ -419,6 +472,56 @@ mod tests {
|
||||
assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_swatch_names_match_naming_each_swatch_as_it_is_added() {
|
||||
let (mut d, _, _) = doc();
|
||||
let mut names = d.free_swatch_names();
|
||||
for base in ["Brand", "Brand", "Mine", "Fresh", "Fresh", "Brand 2", "Brand", REGISTRATION, "Fresh 3", "Fresh", "Mine 2"] {
|
||||
let want = d.free_swatch_name(base);
|
||||
assert_eq!(names.take(base), want, "{base}");
|
||||
d.swatches.push(Swatch { name: want, paint: Paint::solid(Color::BLACK), global: false, spot: false });
|
||||
}
|
||||
let added: Vec<&str> = d.swatches.iter().rev().take(11).rev().map(|w| w.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
added,
|
||||
["Brand 2", "Brand 3", "Mine 2", "Fresh", "Fresh 2", "Brand 2 2", "Brand 4", "[Registration] 2", "Fresh 3", "Fresh 4", "Mine 2 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_swatch_names_match_new_swatch_names_as_swatches_are_added() {
|
||||
let (mut d, _, _) = doc();
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
d.swatches.push(Swatch { name: "New Gradient Swatch 2".into(), paint: Paint::solid(red), global: false, spot: false });
|
||||
let mut names = d.free_swatch_names();
|
||||
// A name taken another way (a group made alongside) counts for both.
|
||||
names.insert("R=255 G=0 B=0 3");
|
||||
d.swatch_groups.push(SwatchGroup { name: "R=255 G=0 B=0 3".into(), swatches: vec![] });
|
||||
let tint = Paint::Solid { color: red, swatch: Some("Brand".into()), tint: 0.4 };
|
||||
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default())));
|
||||
let paints = [Paint::solid(red), Paint::solid(red), gradient.clone(), tint.clone(), Paint::solid(red), gradient.clone(), tint, gradient];
|
||||
let mut added = vec![];
|
||||
for paint in paints {
|
||||
let want = d.new_swatch_name(&paint);
|
||||
assert_eq!(names.take_new(&paint), want);
|
||||
d.swatches.push(Swatch { name: want.clone(), paint, global: false, spot: false });
|
||||
added.push(want);
|
||||
}
|
||||
assert_eq!(
|
||||
added,
|
||||
[
|
||||
"R=255 G=0 B=0",
|
||||
"R=255 G=0 B=0 2",
|
||||
"New Gradient Swatch 1",
|
||||
"Brand 40%",
|
||||
"R=255 G=0 B=0 4",
|
||||
"New Gradient Swatch 3",
|
||||
"Brand 40% 2",
|
||||
"New Gradient Swatch 4"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_copies_only_changed_nodes() {
|
||||
let (mut d, a, b) = doc();
|
||||
|
||||
@@ -228,7 +228,8 @@ pub struct ParaStyle {
|
||||
pub leading_model: LeadingModel,
|
||||
/// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line
|
||||
/// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before
|
||||
/// it and imported text keep [`Burasagari::None`].
|
||||
/// it and imported text keep [`Burasagari::None`], except legacy `.ai` type with binding
|
||||
/// punctuation on, which opens with [`Burasagari::Forced`].
|
||||
pub burasagari: Burasagari,
|
||||
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
|
||||
/// line.
|
||||
|
||||
@@ -39,6 +39,8 @@ pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
/// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a
|
||||
/// document, symbol instances and pattern tiles stay as they are).
|
||||
fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
|
||||
// Envelopes map type from its glyphs here too, without an engine session.
|
||||
vectorcraft_text::register_envelope_ink();
|
||||
let outline = |n: &Node| outline_object(doc, n);
|
||||
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
|
||||
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc })
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Live evaluation without an engine session makes envelopes measure type by its glyph outlines.
|
||||
//! One test in its own process, so nothing else has registered the measure before the evaluation.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
/// The largest distance between the edges of `a` and `b`.
|
||||
fn off(a: Rect, b: Rect) -> f64 {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_evaluation_measures_type_in_envelopes_by_its_glyphs() {
|
||||
let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() });
|
||||
let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
|
||||
let laid = n.geometric_bounds().unwrap();
|
||||
let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap());
|
||||
assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}");
|
||||
let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap();
|
||||
assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}");
|
||||
|
||||
let _ = vectorcraft_effects::expand_live(None, &n);
|
||||
let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap();
|
||||
assert!(off(after, ink) < 1e-6, "after live evaluation, type is measured by its glyphs: {after:?} vs {ink:?}");
|
||||
}
|
||||
@@ -66,3 +66,5 @@ vectorcraft-pdf = { workspace = true, features = ["read-count"] }
|
||||
wat = { workspace = true }
|
||||
# Zstandard streams for the Illustrator editing data fuzz tests.
|
||||
ruzstd = { workspace = true }
|
||||
# The JavaScript parser's lexer, compared with the script checker's on the same scripts.
|
||||
vectorcraft-js = { workspace = true }
|
||||
|
||||
+19
-14
@@ -575,21 +575,26 @@ pub(crate) fn named_id(p: &Value) -> Result<Option<NodeId>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects a command targets: explicit `ids` param or the selection.
|
||||
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
// Objects given are used as given: a value that isn't an object id fails rather than the
|
||||
// command acting on the selection instead (#785).
|
||||
match p.get("ids").filter(|v| !v.is_null()) {
|
||||
Some(Value::Array(a)) => return a.iter().map(object_id).collect(),
|
||||
Some(v) => return Err(EngineError::Other(format!("`ids` must be an array of object ids, not {v}"))),
|
||||
None => {}
|
||||
/// The object `p` gives as `key`, for a command that acts on the selection (or makes something new)
|
||||
/// without it: `None` when `p` leaves it out or gives null. Any other value that isn't an object id
|
||||
/// (`"abc"`, `"5"`, `true`, `{}`, `-1`, `2.5`) is an error under `cmd` that names `key`, never the
|
||||
/// selection. The id is not looked up in the document.
|
||||
pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result<Option<NodeId>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(r)) if key == "id" && r.starts_with('$') => {
|
||||
Err(bad(cmd, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
|
||||
}
|
||||
Some(v) => v.as_u64().map(|id| Some(NodeId(id))).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))),
|
||||
}
|
||||
match p.get("id").filter(|v| !v.is_null()) {
|
||||
Some(v @ Value::Number(_)) => Ok(vec![object_id(v)?]),
|
||||
// A step reference a batch couldn't resolve (other strings are other ids, such as an
|
||||
// effect's).
|
||||
Some(Value::String(r)) if r.starts_with('$') => Err(unresolved_reference(r)),
|
||||
_ => Ok(s.doc()?.selection.objects.clone()),
|
||||
}
|
||||
|
||||
/// Objects a command targets: its `ids`, else its `id` as `ids` of one ([`named_ids`]), else the
|
||||
/// selection. An `ids` or `id` that isn't an object id is an error, never the selection.
|
||||
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match named_ids(p)? {
|
||||
Some(ids) => Ok(ids),
|
||||
None => Ok(s.doc()?.selection.objects.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ pub(super) fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "need at least one point"));
|
||||
}
|
||||
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?);
|
||||
if let Some(id) = id_param(p, "id") {
|
||||
if let Some(id) = crate::cmd::object_param(p, "id", C)? {
|
||||
s.edit("Curvature", |d, sel| {
|
||||
*path_mut(d, id)? = PathData::single(sp);
|
||||
sel.set([id]);
|
||||
|
||||
@@ -222,6 +222,16 @@ pub(crate) const ONE_3D: &str = "An object takes one 3D effect. Edit the existin
|
||||
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.apply";
|
||||
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
|
||||
// An effect given as `id` names no objects: the targets are read with that `id` as null.
|
||||
let effect_as_id;
|
||||
let p = if str_param(p, "effect").is_none() {
|
||||
let mut q = p.clone();
|
||||
q["id"] = Value::Null;
|
||||
effect_as_id = q;
|
||||
&effect_as_id
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let mut params = p.get("params").cloned().unwrap_or(Value::Null);
|
||||
if !params.is_null() && !params.is_object() {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use vectorcraft_doc::cancel::{self, CancelToken, ProgressSink};
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct Progress {
|
||||
total: AtomicU64,
|
||||
/// The steps are files (Export for Screens), not artboards.
|
||||
files: bool,
|
||||
/// The last report came from a plug-in ([`vectorcraft_plugins::host::PROGRESS_STEP`]): its
|
||||
/// steps are its own, shown as a share of the work.
|
||||
share: AtomicBool,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
@@ -43,6 +46,11 @@ impl Progress {
|
||||
self.files
|
||||
}
|
||||
|
||||
/// Are the steps a plug-in's own, which only a share of the work done describes?
|
||||
pub fn counts_share(&self) -> bool {
|
||||
self.share.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// `(steps done, steps in all)`; `total` is 0 while the export can't say (one file made in one go).
|
||||
pub fn counts(&self) -> (u64, u64) {
|
||||
let total = self.total.load(Ordering::Relaxed);
|
||||
@@ -68,7 +76,8 @@ impl Progress {
|
||||
/// Run `f` with `progress`'s token in effect on this thread and the steps reported to it.
|
||||
pub fn scope<R>(progress: &Arc<Progress>, f: impl FnOnce() -> R) -> R {
|
||||
let to = Arc::clone(progress);
|
||||
let sink: ProgressSink = Rc::new(move |done, total, _| {
|
||||
let sink: ProgressSink = Rc::new(move |done, total, what| {
|
||||
to.share.store(what == vectorcraft_plugins::host::PROGRESS_STEP, Ordering::Relaxed);
|
||||
to.total.store(total, Ordering::Relaxed);
|
||||
to.done.store(done, Ordering::Relaxed);
|
||||
});
|
||||
@@ -94,4 +103,14 @@ mod tests {
|
||||
assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{:?}", r.err());
|
||||
assert!(!cancel::is_cancelled(), "the scope ended");
|
||||
}
|
||||
|
||||
/// A plug-in's reports count as a share of the work, not as artboards or files.
|
||||
#[test]
|
||||
fn a_plugin_s_reports_count_as_a_share_of_the_work() {
|
||||
let p = Progress::new();
|
||||
scope(&p, || cancel::progress(1, 4, "artboard"));
|
||||
assert_eq!((p.counts(), p.counts_share()), ((1, 4), false));
|
||||
scope(&p, || cancel::progress(3, 8, vectorcraft_plugins::host::PROGRESS_STEP));
|
||||
assert_eq!((p.counts(), p.counts_share()), ((3, 8), true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::layer::{NewLayers, row_roots, rows_param};
|
||||
use super::{CommandSpec, Result, Session, bad, checked_id, cmd, has_doc, id_param, ok, switch_or};
|
||||
use super::{CommandSpec, Result, Session, bad, checked_id, cmd, has_doc, object_param, ok, switch_or};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -336,7 +336,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.releaseToLayers";
|
||||
let build = switch_or(p, "build", false, C)?;
|
||||
let st = s.doc()?;
|
||||
let id = match id_param(p, "id") {
|
||||
let id = match object_param(p, "id", C)? {
|
||||
Some(id) => id,
|
||||
None => match st.highlighted_rows().as_slice() {
|
||||
[id] => *id,
|
||||
@@ -434,8 +434,9 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn locate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = object_param(p, "id", "layer.locate")?;
|
||||
let st = s.doc_mut()?;
|
||||
let id = id_param(p, "id").or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?;
|
||||
let id = id.or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?;
|
||||
let ancestry = st.doc.ancestry(id).ok_or(EngineError::NoNode(id))?;
|
||||
st.layer_rows = vec![id];
|
||||
st.revision += 1;
|
||||
|
||||
+159
-22
@@ -32,7 +32,7 @@ use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, No
|
||||
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
|
||||
|
||||
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, id_param, ids_param, str_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, ids_param, object_param, str_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Relink",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
|
||||
has_doc,
|
||||
relink
|
||||
),
|
||||
@@ -1125,6 +1125,62 @@ fn update_where(s: &mut Session, ids: Option<Vec<NodeId>>, path: Option<&str>) -
|
||||
Ok(json!({ "updated": updated, "missing": missing }))
|
||||
}
|
||||
|
||||
/// Which links `links.relink` points at the new file (the Replace choice of Relink).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Scope {
|
||||
/// Each link its own file (`paths`).
|
||||
Each,
|
||||
/// The objects given or selected.
|
||||
Selected,
|
||||
/// Every object in the layers linked to the same file as one given or selected.
|
||||
All,
|
||||
/// The objects given or selected, then every other missing link whose file name is in the new
|
||||
/// file's folder.
|
||||
MissingInFolder,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
fn from_id(id: &str) -> Option<Self> {
|
||||
match id {
|
||||
"each" => Some(Scope::Each),
|
||||
"selected" => Some(Scope::Selected),
|
||||
"all" => Some(Scope::All),
|
||||
"missingInFolder" => Some(Scope::MissingInFolder),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for `scope` and `paths` given one without the other, or `paths` beside ids, path or
|
||||
/// folder.
|
||||
fn each_mismatch(cmd: &str) -> EngineError {
|
||||
bad(cmd, "scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder")
|
||||
}
|
||||
|
||||
/// `links.relink {scope: "each", paths}`: each entry's ids with its absolute file, entries naming
|
||||
/// one file merged. A malformed entry, an id in two entries or an empty list is an error.
|
||||
fn each_paths(p: &Value, cmd: &str) -> Result<Vec<(Vec<NodeId>, String)>> {
|
||||
let entries = p.get("paths").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| each_mismatch(cmd))?;
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut out: Vec<(Vec<NodeId>, String)> = vec![];
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let malformed = || bad(cmd, format!("paths entry {i} needs path and ids (object ids no other entry has)"));
|
||||
let path = e.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(malformed)?;
|
||||
let ids = e.get("ids").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(malformed)?;
|
||||
let mut these = vec![];
|
||||
for v in ids {
|
||||
let id = v.as_u64().map(NodeId).filter(|id| seen.insert(*id)).ok_or_else(malformed)?;
|
||||
these.push(id);
|
||||
}
|
||||
let path = absolute_path(path);
|
||||
match out.iter_mut().find(|(_, p)| *p == path) {
|
||||
Some((ids, _)) => ids.extend(these),
|
||||
None => out.push((these, path)),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.relink";
|
||||
// How the new file takes the image's place this time (default: its Placement Options).
|
||||
@@ -1132,13 +1188,26 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
Some(v) => Some(Preserve::from_id(v).ok_or_else(|| bad(C, format!("preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill")))?),
|
||||
};
|
||||
let scope = match p.get("scope").filter(|v| !v.is_null()) {
|
||||
None => Scope::Selected,
|
||||
Some(v) => v.as_str().and_then(Scope::from_id).ok_or_else(|| bad(C, format!("scope {v} is not each|selected|all|missingInFolder")))?,
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let folder_param = str_param(p, "folder");
|
||||
match scope {
|
||||
Scope::Each if p.get("path").is_some() || folder_param.is_some() || p.get("ids").is_some() => return Err(each_mismatch(C)),
|
||||
Scope::Selected | Scope::All | Scope::MissingInFolder if p.get("paths").is_some() => return Err(each_mismatch(C)),
|
||||
Scope::MissingInFolder if folder_param.is_some() => {
|
||||
return Err(bad(C, "scope missingInFolder takes path: the folder of that file is searched for the other missing links"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let each = if scope == Scope::Each { Some(each_paths(p, C)?) } else { None };
|
||||
// With copy: the saved document's folder, which the file is copied into.
|
||||
let copy_to = match (bool_or(p, "copy", false), str_param(p, "path")) {
|
||||
let copy_to = match (bool_or(p, "copy", false), str_param(p, "path").is_some() || each.is_some()) {
|
||||
(false, _) => None,
|
||||
(true, None) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")),
|
||||
(true, Some(_)) => {
|
||||
(true, false) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")),
|
||||
(true, true) => {
|
||||
let doc = st.path.as_deref().map(absolute_path).ok_or_else(|| bad(C, "save the document first: copy puts the file in its folder"))?;
|
||||
Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(|| bad(C, format!("{doc} has no folder")))?)
|
||||
}
|
||||
@@ -1151,9 +1220,10 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let ids: Vec<NodeId> = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => {
|
||||
let mut ids: Vec<NodeId> = match (&each, ids_param(p, "ids")) {
|
||||
(Some(each), _) => each.iter().flat_map(|(ids, _)| ids.iter().copied()).collect(),
|
||||
(None, Some(ids)) => ids,
|
||||
(None, None) => {
|
||||
let selected: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect();
|
||||
match folder_param {
|
||||
Some(_) if selected.is_empty() => {
|
||||
@@ -1169,15 +1239,37 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the images to relink, or pass ids"));
|
||||
}
|
||||
// All Instances: the other objects in the layers linked to the same files.
|
||||
if scope == Scope::All {
|
||||
let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect();
|
||||
let given: BTreeSet<NodeId> = ids.iter().copied().collect();
|
||||
let mut more = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if let Some((l, _, _)) = linked(n)
|
||||
&& paths.contains(l.path.as_str())
|
||||
&& !given.contains(&n.id)
|
||||
{
|
||||
more.push(n.id);
|
||||
}
|
||||
});
|
||||
ids.extend(more);
|
||||
}
|
||||
if copy_to.is_some() && ids.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) {
|
||||
return Err(bad(C, "copy takes images only: relink placed documents where their files are"));
|
||||
}
|
||||
// (ids, the file) per file to read.
|
||||
let mut files: Vec<(Vec<NodeId>, String)> = vec![];
|
||||
let mut not_found = vec![];
|
||||
match (str_param(p, "path"), folder_param) {
|
||||
(Some(path), _) => files.push((ids, absolute_path(path))),
|
||||
(None, Some(dir)) => {
|
||||
match (each, str_param(p, "path"), folder_param) {
|
||||
(Some(each), _, _) => files = each,
|
||||
(None, Some(path), _) if scope == Scope::MissingInFolder => {
|
||||
let path = absolute_path(path);
|
||||
let given: BTreeSet<NodeId> = ids.iter().copied().collect();
|
||||
files.push((ids, path));
|
||||
missing_in_folder(st, &given, copy_to.is_some(), &mut files, &mut not_found);
|
||||
}
|
||||
(None, Some(path), _) => files.push((ids, absolute_path(path))),
|
||||
(None, None, Some(dir)) => {
|
||||
for id in ids {
|
||||
let Some(link) = image(&id).flatten() else { continue };
|
||||
let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy());
|
||||
@@ -1188,10 +1280,10 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None) => return Err(bad(C, "give path (a file) or folder")),
|
||||
(None, None, None) => return Err(bad(C, "give path (a file) or folder")),
|
||||
}
|
||||
let mut reads = vec![];
|
||||
let (mut copied, mut kept) = (vec![], vec![]);
|
||||
// Every file is read and checked before any is copied.
|
||||
let mut checked = vec![];
|
||||
for (ids, path) in files {
|
||||
let bytes = read_file(&path)?;
|
||||
// Images show the file; placed documents each show their artboard or page of it.
|
||||
@@ -1215,20 +1307,28 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for ((page, bounding, page_box), ids) in documents {
|
||||
sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?));
|
||||
}
|
||||
let to = match ©_to {
|
||||
Some(dir) => {
|
||||
let (to, new) = copy_into_folder(&path, &bytes, dir)?;
|
||||
// The bytes are kept only to be copied, the file as it was checked.
|
||||
let link = link_info(&path, &bytes);
|
||||
let bytes = copy_to.is_some().then_some(bytes);
|
||||
checked.push((path, link, bytes, images, img, sources));
|
||||
}
|
||||
let mut reads = vec![];
|
||||
let (mut copied, mut kept): (Vec<Value>, Vec<Value>) = (vec![], vec![]);
|
||||
for (path, link, bytes, images, img, sources) in checked {
|
||||
let link = match (©_to, bytes) {
|
||||
(Some(dir), Some(bytes)) => {
|
||||
let (to, new) = copy_or_remove(&path, &bytes, dir, &copied)?;
|
||||
let link = link_info(&to, &bytes);
|
||||
let pair = json!({ "from": path, "to": to });
|
||||
if new {
|
||||
copied.push(pair)
|
||||
} else {
|
||||
kept.push(pair)
|
||||
}
|
||||
to
|
||||
link
|
||||
}
|
||||
None => path,
|
||||
_ => link,
|
||||
};
|
||||
let link = link_info(&to, &bytes);
|
||||
if let Some(img) = img {
|
||||
reads.push((images, Read::Image((link.clone(), img))));
|
||||
}
|
||||
@@ -1250,6 +1350,41 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `links.relink {scope: "missingInFolder"}`: the other missing links of `st` (not `given`; only
|
||||
/// images when `images_only`, as copy takes), each added to `files` with the file of its name in
|
||||
/// the folder of the first file there; names not there go to `not_found`.
|
||||
fn missing_in_folder(
|
||||
st: &crate::DocState,
|
||||
given: &BTreeSet<NodeId>,
|
||||
images_only: bool,
|
||||
files: &mut Vec<(Vec<NodeId>, String)>,
|
||||
not_found: &mut Vec<String>,
|
||||
) {
|
||||
let Some(dir) = files.first().and_then(|(_, p)| Path::new(p).parent()).map(Path::to_path_buf) else { return };
|
||||
for g in groups(&st.doc, None).into_iter().filter(|g| !(images_only && g.document.is_some())) {
|
||||
let rest: Vec<NodeId> = g.ids.iter().copied().filter(|id| !given.contains(id)).collect();
|
||||
if rest.is_empty() || probe(&g, st.path.as_deref()).0 != Status::Missing {
|
||||
continue;
|
||||
}
|
||||
let found = absolute_path(&dir.join(g.link.name()).to_string_lossy());
|
||||
match files.iter_mut().find(|(_, p)| *p == found) {
|
||||
Some((ids, _)) => ids.extend(rest),
|
||||
None if file_stamp(&found).is_some() => files.push((rest, found)),
|
||||
None => not_found.push(g.link.name().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`copy_into_folder`]; when it fails, the copies `copied` (`{from, to}`) the same call made
|
||||
/// before are removed, since nothing is relinked.
|
||||
fn copy_or_remove(from: &str, bytes: &[u8], dir: &Path, copied: &[Value]) -> Result<(String, bool)> {
|
||||
copy_into_folder(from, bytes, dir).inspect_err(|_| {
|
||||
for to in copied.iter().filter_map(|pair| pair.get("to").and_then(Value::as_str)) {
|
||||
let _ = std::fs::remove_file(to);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `links.relink {copy}`: put the file at `from` (`bytes`, as just read) into the folder `dir`,
|
||||
/// never replacing a file there ([`fileio::copy_new`]) → where it is now, and whether it was
|
||||
/// copied (`false`: a file there with the same contents, of its name or a numbered one, is used).
|
||||
@@ -1298,7 +1433,9 @@ fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) ->
|
||||
|
||||
/// `id`, else the first selected image.
|
||||
fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
let id = id_param(p, "id").or_else(|| selected_images(st, |_| true).first().copied()).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
|
||||
let id = object_param(p, "id", cmd)?
|
||||
.or_else(|| selected_images(st, |_| true).first().copied())
|
||||
.ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
|
||||
match image_of(&st.doc, id) {
|
||||
Some(_) => Ok(id),
|
||||
None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)),
|
||||
@@ -1327,7 +1464,7 @@ fn placement_of(d: &Document, id: NodeId) -> Option<PlacementOptions> {
|
||||
/// `id`, else the first selected image or placed document.
|
||||
fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
let first = || st.selection.objects.iter().copied().find(|id| placement_of(&st.doc, *id).is_some());
|
||||
let id = id_param(p, "id").or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
|
||||
let id = object_param(p, "id", cmd)?.or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
|
||||
match placement_of(&st.doc, id) {
|
||||
Some(_) => Ok(id),
|
||||
None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)),
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::common::{
|
||||
use super::mesh::index_param;
|
||||
use super::{
|
||||
Affine, Anchor, Appearance, Arc, Beside, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, Document, EngineError, Node, NodeId, NodeKind,
|
||||
Paint, PathData, Point, Result, Session, Spine, Value, Vec2, bad, f64_req, id_param, json, live, number_or, ok, str_param, switch_or,
|
||||
Paint, PathData, Point, Result, Session, Spine, Value, Vec2, bad, f64_req, json, live, number_or, ok, str_param, switch_or,
|
||||
};
|
||||
|
||||
/// The first blend among `roots`.
|
||||
@@ -160,7 +160,7 @@ fn edit_spine<T>(
|
||||
label: &str,
|
||||
f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec<usize>, &mut [Arc<Node>]) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let id = match id_param(p, "id") {
|
||||
let id = match crate::cmd::object_param(p, "id", cmd)? {
|
||||
Some(id) => id,
|
||||
None => *selected_of(s, is_blend).first().ok_or_else(|| bad(cmd, "select a blend or give `id`"))?,
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make with Warp…",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Alt+Shift+W"),
|
||||
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected) → {id}",
|
||||
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
|
||||
has_selection,
|
||||
env_make_warp
|
||||
),
|
||||
@@ -145,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make with Mesh…",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Alt+M"),
|
||||
"{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches → {id}",
|
||||
"{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches over their bounds (type measured by its glyph outlines) → {id}",
|
||||
has_selection,
|
||||
env_make_mesh
|
||||
),
|
||||
@@ -154,7 +154,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make with Top Object",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Alt+C"),
|
||||
"{ids?} the topmost selected path becomes the envelope of the others → {id}",
|
||||
"{ids?} the topmost selected path becomes the envelope of the others (type maps from its glyph outline bounds) → {id}",
|
||||
has_multi,
|
||||
env_make_top
|
||||
),
|
||||
@@ -172,7 +172,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset with Mesh…",
|
||||
["Object", "Envelope Distort"],
|
||||
None,
|
||||
"{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content",
|
||||
"{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content, type measured by its glyph outlines), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content",
|
||||
has_envelope,
|
||||
env_reset_mesh
|
||||
),
|
||||
|
||||
@@ -84,7 +84,7 @@ pub(super) fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = roots_param(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect();
|
||||
let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?;
|
||||
let src = live::envelope_content_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?;
|
||||
let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] };
|
||||
let defaults = envelope_defaults(s);
|
||||
let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?;
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, MaskEdit, Node, NodeId, NodeKind, Selection};
|
||||
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, id_param, switch_or};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, object_param, switch_or};
|
||||
|
||||
/// Name of the temporary mask-editing layer.
|
||||
pub const MASK_EDIT_LAYER: &str = "Opacity Mask Editing";
|
||||
@@ -117,7 +117,7 @@ fn enter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.doc.mask_edit.is_some() {
|
||||
return Err(bad(C, "already editing an opacity mask"));
|
||||
}
|
||||
let id = id_param(p, "id").or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
|
||||
let id = object_param(p, "id", C)?.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
|
||||
let layer = start(s, id)?;
|
||||
Ok(json!({ "layer": layer.0 }))
|
||||
}
|
||||
@@ -136,6 +136,7 @@ fn start(s: &mut Session, id: NodeId) -> Result<NodeId> {
|
||||
|
||||
fn view_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "transparency.viewOpacityMask";
|
||||
let given = object_param(p, "id", C)?;
|
||||
let st = s.doc()?;
|
||||
let editing = st.doc.mask_edit.map(|m| m.object);
|
||||
if !switch_or(p, "on", st.shown_mask().is_none(), C)? {
|
||||
@@ -144,7 +145,7 @@ fn view_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.revision += 1;
|
||||
return Ok(json!({ "on": false, "id": id.map(|i| i.0) }));
|
||||
}
|
||||
let id = id_param(p, "id").or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
|
||||
let id = given.or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
|
||||
match editing {
|
||||
Some(e) if e != id => return Err(bad(C, "already editing another object's opacity mask")),
|
||||
Some(_) => {}
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_tra
|
||||
use super::opacitymask::percent;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_multi, has_selection,
|
||||
has_selection_or_guides, id_param, ids_param, matrix_param, ok, point_param, str_param, targets,
|
||||
has_selection_or_guides, ids_param, matrix_param, object_param, ok, point_param, str_param, targets,
|
||||
};
|
||||
use crate::{EngineError, LastTransform};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::align::choice;
|
||||
use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, id_param, json, ok, selected_roots};
|
||||
use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, json, object_param, ok, selected_roots};
|
||||
|
||||
pub(super) enum Arrange {
|
||||
Front,
|
||||
@@ -293,7 +293,7 @@ pub(super) fn show_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id")
|
||||
let id = object_param(p, "id", "object.isolate")?
|
||||
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
|
||||
.ok_or_else(|| bad("object.isolate", "missing id"))?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
|
||||
@@ -47,7 +47,7 @@ use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use vectorcraft_pdf::CropTo;
|
||||
|
||||
use super::fileio::{self, Format, RasterImage};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, id_param, str_param, unique_name};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, object_param, str_param, unique_name};
|
||||
use crate::{EngineError, MAX_COORD};
|
||||
|
||||
const PLACE: &str = "file.place";
|
||||
@@ -866,7 +866,7 @@ pub fn read_info(p: &Value) -> Result<Value> {
|
||||
pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "image.info";
|
||||
let st = s.doc()?;
|
||||
let id = match (id_param(p, "id"), &st.selection.objects[..]) {
|
||||
let id = match (object_param(p, "id", C)?, &st.selection.objects[..]) {
|
||||
(Some(id), _) => id,
|
||||
(None, [id]) => *id,
|
||||
_ => return Err(bad(C, "select one image or pass id")),
|
||||
|
||||
+109
-25
@@ -13,6 +13,18 @@
|
||||
//! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`),
|
||||
//! which loading a folder doesn't ask, so folders skip it; the app runs it in the background with
|
||||
//! Cancel ([`FilterRun`]).
|
||||
//!
|
||||
//! A plug-in whose manifest lists permissions installs once the user grants them
|
||||
//! (`plugin.install {grant}`, recorded with the module's hash and kept in the plug-in store), and
|
||||
//! a filter does only what they allow: without `doc.read` it is given no objects, without
|
||||
//! `doc.write` an output that changes the document is refused, and without `state` it neither
|
||||
//! reads nor writes the data it keeps with the document.
|
||||
//!
|
||||
//! A plug-in of API level 2 that lists `progress` may import the host's `vc_progress`,
|
||||
//! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a
|
||||
//! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its
|
||||
//! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log
|
||||
//! under its id.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -22,6 +34,8 @@ use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registr
|
||||
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or};
|
||||
|
||||
use vectorcraft_plugins::permissions::{self, Grant, Permission};
|
||||
|
||||
/// What a plug-in error looks like to the user. A run the caller cancelled changed nothing.
|
||||
fn err(e: vectorcraft_plugins::Error) -> EngineError {
|
||||
match e {
|
||||
@@ -37,6 +51,7 @@ pub fn describe(p: &Plugin) -> Value {
|
||||
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description,
|
||||
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
|
||||
"size": p.size(), "source": p.source(),
|
||||
"permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api,
|
||||
});
|
||||
match m.kind {
|
||||
Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)),
|
||||
@@ -122,15 +137,17 @@ impl FilterRun {
|
||||
}
|
||||
// Bad parameters fail before anything runs.
|
||||
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
|
||||
let may = m.permissions();
|
||||
let st = s.doc()?;
|
||||
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
|
||||
let inputs = objects::filter_targets(&st.doc, &selected);
|
||||
// Without `doc.read` a filter is given no objects: it can only add new ones.
|
||||
let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() };
|
||||
let parent = st.insertion_parent();
|
||||
let bounds = st.doc.bounds_of(&inputs, false);
|
||||
let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first());
|
||||
let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
|
||||
// What the plug-in keeps with the document, as it last wrote it.
|
||||
let data = st.doc.plugin_data(&m.id).cloned().unwrap_or(Value::Null);
|
||||
let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null);
|
||||
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data});
|
||||
let input = objects::encode_input(&st.doc, &inputs);
|
||||
let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?;
|
||||
@@ -175,13 +192,23 @@ impl FilterRun {
|
||||
}
|
||||
|
||||
/// Applies the plug-in's `output` to the active document as one undo step: its objects, and what
|
||||
/// it keeps with the document from now on.
|
||||
/// it keeps with the document from now on. An output its permissions don't allow is refused,
|
||||
/// and the document stays as it was.
|
||||
fn finish(self, s: &mut Session, output: &[u8]) -> Result<Value> {
|
||||
let m = self.plugin.manifest();
|
||||
let may = m.permissions();
|
||||
let out = objects::decode_output(output).map_err(err)?;
|
||||
if out.document_data.is_some() && !may.contains(&Permission::State) {
|
||||
return Err(not_granted(&m.name, Permission::State));
|
||||
}
|
||||
let may_write = may.contains(&Permission::DocWrite);
|
||||
let (inputs, parent) = (self.inputs, self.parent);
|
||||
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
|
||||
let before = (!may_write).then(|| d.layers.clone());
|
||||
let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?;
|
||||
if before.is_some_and(|b| b != d.layers) {
|
||||
return Err(not_granted(&m.name, Permission::DocWrite));
|
||||
}
|
||||
if let Some(data) = out.document_data {
|
||||
d.set_plugin_data(&m.id, data);
|
||||
}
|
||||
@@ -192,6 +219,12 @@ impl FilterRun {
|
||||
}
|
||||
}
|
||||
|
||||
/// The error of a filter `name` whose output needs `permission`, which it wasn't granted.
|
||||
fn not_granted(name: &str, permission: Permission) -> EngineError {
|
||||
let (name, p) = (permissions::display_text(name, permissions::MAX_SHOWN_NAME), permission.name());
|
||||
err(vectorcraft_plugins::Error::Output(format!("“{name}” wasn't granted `{p}`, which its output needs: nothing was changed")))
|
||||
}
|
||||
|
||||
/// `plugin.run`: runs filter `id` on `ids` (else the selected paths and compound paths) with its
|
||||
/// parameters and `_context` (what it keeps with the document included), and applies its output as
|
||||
/// one undo step ([`objects::apply_output`]; effect records as [`admit_effects`] admits them), with
|
||||
@@ -283,13 +316,37 @@ fn load_given(c: &str, p: &Value) -> Result<(Plugin, Vec<u8>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The permissions `grant` lists in command `c`'s params: `None` when it is left out (or null).
|
||||
/// Anything but a list of permission names is an error.
|
||||
fn grant_param(c: &str, p: &Value) -> Result<Option<Vec<Permission>>> {
|
||||
let Some(v) = p.get("grant").filter(|v| !v.is_null()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let names = v.as_array().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?;
|
||||
let mut out = Vec::with_capacity(names.len());
|
||||
for n in names {
|
||||
let name = n.as_str().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?;
|
||||
let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD);
|
||||
out.push(Permission::parse(name).ok_or_else(|| bad(c, format!("`grant` names an unknown permission `{shown}`")))?);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless
|
||||
/// `allowHeavy` says the user agreed, keeps it for the next start and installs it → its
|
||||
/// `plugin.list` entry.
|
||||
/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id
|
||||
/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant
|
||||
/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry.
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.install";
|
||||
let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?);
|
||||
let grant = grant_param(C, p)?;
|
||||
let (plugin, bytes) = load_given(C, p)?;
|
||||
// `hash` pins the module the user was shown: one with other bytes is refused.
|
||||
if let Some(h) = p.get("hash").filter(|v| !v.is_null())
|
||||
&& h.as_str() != Some(plugin.hash())
|
||||
{
|
||||
return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed"));
|
||||
}
|
||||
if !replace && registry::get(plugin.id()).is_some() {
|
||||
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
|
||||
}
|
||||
@@ -304,14 +361,33 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
),
|
||||
));
|
||||
}
|
||||
// A plug-in that lists permissions installs once the user grants them: whoever installs it
|
||||
// asks them first, unless they already granted its id all of them.
|
||||
let m = plugin.manifest();
|
||||
let wanted = m.permissions();
|
||||
if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) {
|
||||
let name = permissions::display_text(&m.name, permissions::MAX_SHOWN_NAME);
|
||||
let asked = permissions::names(&wanted).join(", ");
|
||||
return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees")));
|
||||
}
|
||||
if m.asks_permissions() {
|
||||
let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted };
|
||||
registry::grant(g.clone());
|
||||
s.store_grant(&m.id, Some(g));
|
||||
}
|
||||
// Installed plug-ins stay installed after a restart (#2541).
|
||||
s.store_plugin(plugin.id(), &bytes);
|
||||
Ok(describe(®istry::install(plugin)))
|
||||
}
|
||||
|
||||
/// `plugin.inspect`: what installing the module would install, without installing it.
|
||||
fn inspect(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
load_given("plugin.inspect", p).map(|(plugin, _)| describe(&plugin))
|
||||
/// `plugin.inspect`: what installing the module would install, without installing it, and whether
|
||||
/// that needs the user to grant its permissions first (`needsApproval`).
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
load_given("plugin.inspect", p).map(|(plugin, _)| {
|
||||
let mut v = describe(&plugin);
|
||||
v["needsApproval"] = json!(!s.plugin_approved(&plugin));
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
/// The plug-in in the `.wasm` file `path`, and its module.
|
||||
@@ -328,35 +404,40 @@ fn load_path(_: &str) -> Result<(Plugin, Vec<u8>)> {
|
||||
Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into()))
|
||||
}
|
||||
|
||||
/// The plug-in folder last loaded from the preferences, and what happened.
|
||||
static FOLDER: Mutex<Option<(String, Value)>> = Mutex::new(None);
|
||||
/// The plug-in folder last loaded, the plug-in store whose grants it was loaded with, and what
|
||||
/// happened.
|
||||
static FOLDER: Mutex<Option<(String, Option<std::path::PathBuf>, Value)>> = Mutex::new(None);
|
||||
|
||||
/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and has changed since
|
||||
/// the last load. Runs whenever preferences are applied (startup, `prefs.set`). Native only.
|
||||
/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and it, or the
|
||||
/// session's plug-in store, has changed since the last load. Runs whenever preferences are applied
|
||||
/// (startup, `prefs.set`). Native only.
|
||||
pub(crate) fn sync_prefs(s: &Session) {
|
||||
let folder = s.prefs.plugins_folder.trim();
|
||||
if folder.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_ref().is_some_and(|(f, _)| f == folder) {
|
||||
if last.as_ref().is_some_and(|(f, store, _)| f == folder && *store == s.plugin_dir) {
|
||||
return;
|
||||
}
|
||||
*last = Some((folder.to_string(), load_folder(folder)));
|
||||
*last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder)));
|
||||
}
|
||||
|
||||
/// Loads the plug-ins in `folder`, those that list permissions only when `s` has them granted
|
||||
/// ([`Session::plugin_approved`], with the store's grants read once).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_folder(folder: &str) -> Value {
|
||||
fn load_folder(s: &Session, folder: &str) -> Value {
|
||||
if let Err(e) = crate::file_access::check_read(folder) {
|
||||
return json!({"folder": folder, "error": e});
|
||||
}
|
||||
// A file in it may be a link to one elsewhere.
|
||||
match registry::load_folder_where(std::path::Path::new(folder), |f| match f.to_str() {
|
||||
let allow = |f: &std::path::Path| match f.to_str() {
|
||||
Some(f) => crate::file_access::check_read(f),
|
||||
// A lossy name would check another file than the one loaded.
|
||||
None if crate::file_access::current().is_none() => Ok(()),
|
||||
None => Err(format!("automation path rejected: {} isn't valid text", f.display())),
|
||||
}) {
|
||||
};
|
||||
match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) {
|
||||
Ok(r) => {
|
||||
json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()})
|
||||
}
|
||||
@@ -365,7 +446,7 @@ fn load_folder(folder: &str) -> Value {
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_folder(folder: &str) -> Value {
|
||||
fn load_folder(_: &Session, folder: &str) -> Value {
|
||||
json!({"folder": folder, "error": "plug-in folders are not available on the web"})
|
||||
}
|
||||
|
||||
@@ -378,16 +459,16 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if folder.is_empty() {
|
||||
return Err(bad(C, "no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder"));
|
||||
}
|
||||
let report = load_folder(&folder);
|
||||
let report = load_folder(s, &folder);
|
||||
if let Some(e) = report.get("error").and_then(Value::as_str) {
|
||||
return Err(EngineError::Other(e.to_string()));
|
||||
}
|
||||
*FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone()));
|
||||
*FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, s.plugin_dir.clone(), report.clone()));
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone());
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone());
|
||||
Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder}))
|
||||
}
|
||||
|
||||
@@ -402,6 +483,9 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
|
||||
// Installed again, it asks again.
|
||||
registry::revoke(id);
|
||||
s.store_grant(id, None);
|
||||
s.unstore_plugin(id);
|
||||
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
|
||||
}
|
||||
@@ -415,7 +499,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -442,7 +526,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Install Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
always,
|
||||
install
|
||||
),
|
||||
@@ -451,7 +535,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Inspect Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)",
|
||||
always,
|
||||
inspect
|
||||
),
|
||||
@@ -461,7 +545,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reload Plug-ins",
|
||||
["Object", "Plug-ins"],
|
||||
None,
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
always,
|
||||
reload
|
||||
),
|
||||
|
||||
@@ -152,6 +152,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
|
||||
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
|
||||
p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool),
|
||||
p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool),
|
||||
// Selection & Anchor Display
|
||||
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
|
||||
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in
|
||||
//! the same undo transaction. Rendering and exporters only see ordinary groups and paths.
|
||||
use super::pathops::Beside;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, point_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, object_param, point_param};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
@@ -785,15 +785,15 @@ fn end_isolation(s: &mut Session) -> Result<()> {
|
||||
st.isolation = editing_layer(&st.doc);
|
||||
Ok(())
|
||||
}
|
||||
fn target(s: &Session, p: &Value) -> Result<NodeId> {
|
||||
let id = id_param(p, "id")
|
||||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
let id = object_param(p, "id", cmd)?
|
||||
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
|
||||
.ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
|
||||
s.doc()?.doc.node(id).filter(|n| n.extras().shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
|
||||
.ok_or_else(|| bad(cmd, "select a Shaper group"))?;
|
||||
s.doc()?.doc.node(id).filter(|n| n.extras().shaper.is_some()).ok_or_else(|| bad(cmd, "select a Shaper group"))?;
|
||||
Ok(id)
|
||||
}
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p)?;
|
||||
let id = target(s, p, "shaper.release")?;
|
||||
let ids = s.edit("Release Shaper Group", |d, sel| {
|
||||
let original = d.node(id).map(sources).ok_or(EngineError::NoNode(id))?;
|
||||
let (parent, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
@@ -809,7 +809,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p)?;
|
||||
let id = target(s, p, "shaper.expand")?;
|
||||
s.edit("Expand Shaper Group", |d, sel| {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
n.extras_mut().shaper = None;
|
||||
|
||||
@@ -20,7 +20,7 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, index, ok, str_param, unique_name};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -290,7 +290,7 @@ fn style_index(d: &Document, name: &str) -> Result<usize> {
|
||||
|
||||
/// The object a style is made from: `id`, else the first selected object.
|
||||
fn source<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(NodeId, &'a Node)> {
|
||||
let id = match id_param(p, "id") {
|
||||
let id = match object_param(p, "id", cmd)? {
|
||||
Some(id) => id,
|
||||
None => super::edit::selected_roots(s)?.first().copied().ok_or_else(|| bad(cmd, "select an object (or pass `id`)"))?,
|
||||
};
|
||||
|
||||
+122
-27
@@ -1,16 +1,15 @@
|
||||
//! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent};
|
||||
use vectorcraft_doc::pattern::pattern_paint;
|
||||
use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors};
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
|
||||
use super::paint::paint_from;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, index, ok, str_param, switch_or,
|
||||
unique_name,
|
||||
};
|
||||
use super::{CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, index, ok, str_param, switch_or};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -195,10 +194,11 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let requested = name_param(p, "name").filter(|_| paints.len() == 1);
|
||||
let names = s.edit("New Swatch", |d, _| {
|
||||
let mut names = vec![];
|
||||
let mut free = d.free_swatch_names();
|
||||
for paint in paints {
|
||||
let name = match &requested {
|
||||
Some(n) => d.free_swatch_name(n),
|
||||
None => d.new_swatch_name(&paint),
|
||||
Some(n) => free.take(n),
|
||||
None => free.take_new(&paint),
|
||||
};
|
||||
// A tint swatch's kind is its base's.
|
||||
let tint = matches!(paint, Paint::Solid { swatch: Some(_), .. });
|
||||
@@ -271,36 +271,116 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// each). A tint (a linked colour that differs from its swatch's) brings its swatch, and with
|
||||
/// `tints` its own colour too. Links to missing swatches count as unlinked.
|
||||
fn artwork_colors(d: &Document, ids: &[NodeId], tints: bool, swatches: &mut Vec<String>, colors: &mut Vec<Color>) {
|
||||
let by_name = swatches_by_name(d);
|
||||
let mut seen_swatches: HashSet<String> = swatches.iter().cloned().collect();
|
||||
let mut seen_colors: HashSet<ColorKey> = colors.iter().filter_map(color_key).collect();
|
||||
for n in ids.iter().filter_map(|id| d.node(*id)) {
|
||||
node_colors(n, &mut |c, link| {
|
||||
let sw = link.and_then(|l| d.swatch(l)).filter(|w| w.paint.color().is_some());
|
||||
let sw = link.and_then(|l| by_name.get(l).copied()).filter(|w| w.paint.color().is_some());
|
||||
if let Some(w) = sw
|
||||
&& !swatches.contains(&w.name)
|
||||
&& !seen_swatches.contains(&w.name)
|
||||
{
|
||||
seen_swatches.insert(w.name.clone());
|
||||
swatches.push(w.name.clone());
|
||||
}
|
||||
let own = sw.is_none_or(|w| tints && w.paint.color() != Some(*c));
|
||||
if own && !colors.contains(c) {
|
||||
// A color with a NaN component equals no color, so it is never already listed.
|
||||
if own && color_key(c).is_none_or(|k| seen_colors.insert(k)) {
|
||||
colors.push(*c);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Link the solid paints in the subtrees of `ids` whose colour is one of `links`' to that swatch,
|
||||
/// unless they already link to an existing swatch. Returns the number of paints linked.
|
||||
/// Every swatch by name, as [`Document::swatch`] finds it: the first swatch of each name, and the
|
||||
/// built-in Registration swatch when no swatch has its name.
|
||||
fn swatches_by_name(d: &Document) -> HashMap<&str, &Swatch> {
|
||||
let mut by_name = HashMap::new();
|
||||
for w in d.swatches_iter().chain([vectorcraft_color::swatch::registration()]) {
|
||||
by_name.entry(w.name.as_str()).or_insert(w);
|
||||
}
|
||||
by_name
|
||||
}
|
||||
|
||||
/// A color's model and the bits of its components, with -0.0 read as 0.0: two keys are equal
|
||||
/// exactly when the colors are equal (`==`). `None` for a color with a NaN component, which
|
||||
/// equals no color.
|
||||
type ColorKey = (u8, [u32; 4]);
|
||||
|
||||
fn color_key(c: &Color) -> Option<ColorKey> {
|
||||
let (model, v) = match *c {
|
||||
Color::Rgb { r, g, b } => (0, [r, g, b, 0.0]),
|
||||
Color::Cmyk { c, m, y, k } => (1, [c, m, y, k]),
|
||||
Color::Gray { k } => (2, [k, 0.0, 0.0, 0.0]),
|
||||
Color::Lab { l, a, b } => (3, [l, a, b, 0.0]),
|
||||
};
|
||||
Some((model, [float_key(v[0])?, float_key(v[1])?, float_key(v[2])?, float_key(v[3])?]))
|
||||
}
|
||||
|
||||
/// The bits of `v`, with -0.0 read as 0.0, so keys are equal exactly when the values are; `None`
|
||||
/// for NaN.
|
||||
fn float_key(v: f32) -> Option<u32> {
|
||||
if v.is_nan() {
|
||||
None
|
||||
} else if v == 0.0 {
|
||||
Some(0)
|
||||
} else {
|
||||
Some(v.to_bits())
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient stop's offset, color, opacity, midpoint, swatch link, tint and process plates, as
|
||||
/// keys.
|
||||
type StopKey<'a> = (u32, ColorKey, u32, u32, Option<&'a str>, u32, Option<[u32; 4]>);
|
||||
|
||||
/// A gradient's kind, method, dither and stops, as keys.
|
||||
type GradientKey<'a> = (u8, u8, bool, Vec<StopKey<'a>>);
|
||||
|
||||
/// A key that is equal for two gradients exactly when the gradients are equal (`==`); `None` when
|
||||
/// any of its numbers is NaN, so the gradient equals no gradient.
|
||||
fn gradient_key(g: &Gradient) -> Option<GradientKey<'_>> {
|
||||
let kind = match g.kind {
|
||||
GradientKind::Linear => 0,
|
||||
GradientKind::Radial => 1,
|
||||
GradientKind::Freeform => 2,
|
||||
};
|
||||
let method = match g.method {
|
||||
GradientMethod::Classic => 0,
|
||||
GradientMethod::Perceptual => 1,
|
||||
};
|
||||
Some((kind, method, g.dither, g.stops.iter().map(stop_key).collect::<Option<_>>()?))
|
||||
}
|
||||
|
||||
/// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN.
|
||||
fn stop_key(s: &GradientStop) -> Option<StopKey<'_>> {
|
||||
let plates = match s.plates {
|
||||
Some(p) => Some([float_key(p[0])?, float_key(p[1])?, float_key(p[2])?, float_key(p[3])?]),
|
||||
None => None,
|
||||
};
|
||||
Some((float_key(s.offset)?, color_key(&s.color)?, float_key(s.opacity)?, float_key(s.midpoint)?, s.swatch.as_deref(), float_key(s.tint)?, plates))
|
||||
}
|
||||
|
||||
/// Link the solid paints in the subtrees of `ids` whose color is one of `links`' to that swatch
|
||||
/// (the first one listed for the color), unless they already link to an existing swatch or to the
|
||||
/// built-in Registration swatch. Returns the number of paints linked.
|
||||
fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> usize {
|
||||
if links.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let live: Vec<String> = d.swatches_iter().map(|w| w.name.clone()).collect();
|
||||
let live: HashSet<String> = d.swatches_iter().chain([vectorcraft_color::swatch::registration()]).map(|w| w.name.clone()).collect();
|
||||
let mut by_color: HashMap<ColorKey, &String> = HashMap::new();
|
||||
for (c, n) in links {
|
||||
if let Some(k) = color_key(c) {
|
||||
by_color.entry(k).or_insert(n);
|
||||
}
|
||||
}
|
||||
d.map_solid_paints_in(ids, &mut |c, link, _| {
|
||||
if link.as_ref().is_some_and(|l| live.contains(l)) {
|
||||
return false;
|
||||
}
|
||||
match links.iter().find(|(lc, _)| lc == c) {
|
||||
Some((_, n)) => {
|
||||
*link = Some(n.clone());
|
||||
match color_key(c).and_then(|k| by_color.get(&k)) {
|
||||
Some(n) => {
|
||||
*link = Some((*n).clone());
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
@@ -346,8 +426,11 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// The global swatches made from the art's colours, which its unlinked paints link to.
|
||||
let mut links: Vec<(Color, String)> = vec![];
|
||||
let mut free = d.free_swatch_names();
|
||||
free.insert(&name);
|
||||
group.swatches.iter().for_each(|sw| free.insert(&sw.name));
|
||||
for (c, global) in new {
|
||||
let nm = unique_name(&color_name(c), |n| n == name || d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n));
|
||||
let nm = free.take(&color_name(c));
|
||||
if global {
|
||||
links.push((c, nm.clone()));
|
||||
}
|
||||
@@ -798,10 +881,12 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut linked_to, mut colors) = (vec![], vec![]);
|
||||
artwork_colors(d, &ids, false, &mut linked_to, &mut colors);
|
||||
// Colours a solid swatch already has are skipped.
|
||||
colors.retain(|c| !d.swatches_iter().any(|w| w.paint.color() == Some(*c)));
|
||||
let have: HashSet<ColorKey> = d.swatches_iter().filter_map(|w| w.paint.color()).filter_map(|c| color_key(&c)).collect();
|
||||
colors.retain(|c| color_key(c).is_none_or(|k| !have.contains(&k)));
|
||||
let mut free = d.free_swatch_names();
|
||||
let mut links = vec![];
|
||||
for c in colors {
|
||||
let name = d.free_swatch_name(&color_name(c));
|
||||
let name = free.take(&color_name(c));
|
||||
d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(c), global, spot: false });
|
||||
links.push((c, name));
|
||||
}
|
||||
@@ -813,10 +898,11 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let by_name = swatches_by_name(d);
|
||||
// Links with their tints, so a tint swatch is used where its tint of its base is.
|
||||
let (mut links, mut colors, mut gradients, mut patterns, mut gradient_links) = (vec![], vec![], vec![], vec![], vec![]);
|
||||
let mut color = |c: &Color, link: Option<&String>, tint: f32| match link {
|
||||
Some(l) if d.swatch(l).is_some() => links.push((l.clone(), tint)),
|
||||
Some(l) if by_name.contains_key(l.as_str()) => links.push((l.clone(), tint)),
|
||||
_ => colors.push(*c),
|
||||
};
|
||||
let mut paint = |p: &Paint| match p {
|
||||
@@ -838,16 +924,22 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let gradient_links: HashSet<&String> = gradient_links.iter().collect();
|
||||
let link_names: HashSet<&str> = links.iter().map(|(l, _)| l.as_str()).collect();
|
||||
let link_tints: HashSet<(&str, u32)> = links.iter().filter_map(|(l, t)| Some((l.as_str(), float_key(*t)?))).collect();
|
||||
let colors: HashSet<ColorKey> = colors.iter().filter_map(color_key).collect();
|
||||
let patterns: HashSet<&String> = patterns.iter().collect();
|
||||
let gradients: HashSet<GradientKey> = gradients.iter().filter_map(gradient_key).collect();
|
||||
let used = |w: &Swatch| {
|
||||
gradient_links.contains(&w.name)
|
||||
|| links.iter().any(|(l, t)| match w.tint_of() {
|
||||
Some((base, tint)) => l == base && *t == tint,
|
||||
None => *l == w.name,
|
||||
})
|
||||
|| match w.tint_of() {
|
||||
Some((base, tint)) => float_key(tint).is_some_and(|t| link_tints.contains(&(base, t))),
|
||||
None => link_names.contains(w.name.as_str()),
|
||||
}
|
||||
|| match &w.paint {
|
||||
Paint::None => true,
|
||||
Paint::Solid { color, .. } => colors.contains(color),
|
||||
Paint::Gradient(g) => gradients.contains(&g.gradient),
|
||||
Paint::Solid { color, .. } => color_key(color).is_some_and(|k| colors.contains(&k)),
|
||||
Paint::Gradient(g) => gradient_key(&g.gradient).is_some_and(|k| gradients.contains(&k)),
|
||||
Paint::Pattern { pattern, .. } => patterns.contains(pattern),
|
||||
}
|
||||
};
|
||||
@@ -1017,8 +1109,11 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut free = d.free_swatch_names();
|
||||
free.insert(&name);
|
||||
swatches.iter().for_each(|w| free.insert(&w.name));
|
||||
for c in colors.iter().skip(swatches.len()) {
|
||||
let nm = unique_name(&color_name(*c), |n| n == name || d.swatch_name_taken(n) || swatches.iter().any(|w| w.name == n));
|
||||
let nm = free.take(&color_name(*c));
|
||||
swatches.push(Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false });
|
||||
}
|
||||
let g = &mut d.swatch_groups[gi];
|
||||
|
||||
@@ -20,7 +20,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, S
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::brushsym::{natural_size, place_xf};
|
||||
use super::{EngineError, Result, Session, bad, id_param, number_or, ok, str_param};
|
||||
use super::{EngineError, Result, Session, bad, number_or, object_param, ok, str_param};
|
||||
|
||||
/// The name of the undo step that enters symbol editing mode.
|
||||
const ENTER: &str = "Edit Symbol";
|
||||
@@ -65,7 +65,7 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.doc.pattern_edit.is_some() {
|
||||
return Err(bad(C, "finish editing the pattern first"));
|
||||
}
|
||||
let given = id_param(p, "id").map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty()));
|
||||
let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty()));
|
||||
let (name, xf, instance) = match given {
|
||||
Some(ids) => {
|
||||
let symbols = ids.iter().map(|id| instance_of(&st.doc, *id)).collect::<Option<Vec<_>>>();
|
||||
|
||||
@@ -1676,7 +1676,7 @@ pub(crate) fn type_ranges(
|
||||
at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>,
|
||||
) -> Result<Vec<(NodeId, usize, usize, bool)>> {
|
||||
// (text, its range; None: all of it).
|
||||
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match id_param(p, "id") {
|
||||
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? {
|
||||
// An object that isn't type is refused as `ids` of it is.
|
||||
Some(id) if s.doc()?.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Text(_))) => {
|
||||
return Err(bad(c, "no text objects selected"));
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, Te
|
||||
|
||||
use super::clipboard::resources::import_text_styles;
|
||||
use super::typecmd::{refresh_bounds, text_targets};
|
||||
use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, id_param, index, ok, str_param};
|
||||
use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, index, object_param, ok, str_param};
|
||||
|
||||
pub const NORMAL_CHAR: &str = "[Normal Character Style]";
|
||||
pub const NORMAL_PARA: &str = "[Normal Paragraph Style]";
|
||||
@@ -439,10 +439,11 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
|
||||
|
||||
/// The selected text's attributes: the first selected character for a nonempty range,
|
||||
/// the insertion style at a lone `start`, or the first selected text object's first run.
|
||||
/// Paragraph capture always samples the paragraph at `start`.
|
||||
fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<String, Value>>> {
|
||||
/// Paragraph capture always samples the paragraph at `start`. An `id` that isn't an object id is
|
||||
/// an error under the `verb` command of `kind`.
|
||||
fn selection_attrs(s: &Session, p: &Value, kind: Kind, verb: &str) -> Result<Option<Map<String, Value>>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let id = match id_param(p, "id") {
|
||||
let id = match object_param(p, "id", &kind.cmd(verb))? {
|
||||
Some(id) => Some(id),
|
||||
None => text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()),
|
||||
};
|
||||
@@ -464,14 +465,14 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
|
||||
/// The selected text's character (`para`: paragraph) attributes, every one of them: what a
|
||||
/// library keeps of a text style. `None` without text selected.
|
||||
pub(crate) fn selected_style_attrs(s: &Session, para: bool) -> Result<Option<Map<String, Value>>> {
|
||||
selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char })
|
||||
selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char }, "new")
|
||||
}
|
||||
|
||||
/// The attributes `charStyle.new` (`para`: `paraStyle.new`) would give a style made now from
|
||||
/// `target` (its `id`, `start` and `end`; empty for the selected objects). `None` without text
|
||||
/// there.
|
||||
pub fn target_style_attrs(s: &Session, target: &Value, para: bool) -> Option<Map<String, Value>> {
|
||||
selection_attrs(s, target, if para { Kind::Para } else { Kind::Char }).ok().flatten()
|
||||
selection_attrs(s, target, if para { Kind::Para } else { Kind::Char }, "new").ok().flatten()
|
||||
}
|
||||
|
||||
/// The attributes a new character or paragraph style receives when no text is selected.
|
||||
@@ -644,7 +645,7 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
/// (`charStyle.duplicate`), which `attrs` keeps as [`attrs_param`] keeps the target text's.
|
||||
fn new_from(s: &mut Session, p: &Value, kind: Kind, copied: Option<&Map<String, Value>>) -> Result<Value> {
|
||||
let c = kind.cmd("new");
|
||||
let target = selection_attrs(s, p, kind)?;
|
||||
let target = selection_attrs(s, p, kind, "new")?;
|
||||
let attrs = match attrs_param(&s.doc()?.doc, p, kind, "new", copied.or(target.as_ref()))? {
|
||||
Some(a) => a,
|
||||
None => match target {
|
||||
@@ -685,8 +686,9 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
let style_name = (name != kind.normal()).then(|| name.clone());
|
||||
// A Type tool range or whole text objects.
|
||||
let text_range = super::typecmd::TextRange::parse(p, &c)?;
|
||||
let range = id_param(p, "id").filter(|_| text_range.is_some());
|
||||
let ids = match range.or_else(|| id_param(p, "id")) {
|
||||
let given = object_param(p, "id", &c)?;
|
||||
let range = given.filter(|_| text_range.is_some());
|
||||
let ids = match given {
|
||||
Some(id) => vec![id],
|
||||
None => text_targets(s, p, &c)?,
|
||||
};
|
||||
@@ -767,7 +769,7 @@ fn redefine_to(s: &mut Session, kind: Kind, name: &str, new_attrs: Map<String, V
|
||||
|
||||
fn redefine(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
let name = name_param(p, kind, "redefine")?.to_string();
|
||||
let attrs = selection_attrs(s, p, kind)?.ok_or_else(|| bad(&kind.cmd("redefine"), "select text to redefine the style from"))?;
|
||||
let attrs = selection_attrs(s, p, kind, "redefine")?.ok_or_else(|| bad(&kind.cmd("redefine"), "select text to redefine the style from"))?;
|
||||
redefine_to(s, kind, &name, attrs, "redefine")
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ pub struct Prefs {
|
||||
pub scrub_numeric_fields: bool,
|
||||
/// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°.
|
||||
pub trackpad_rotate_view: bool,
|
||||
/// A single-key tool shortcut held down chooses its tool until the key is let go, and then the
|
||||
/// tool active before comes back; a quick press chooses the tool for good.
|
||||
pub hold_tool_keys: bool,
|
||||
/// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this).
|
||||
pub paste_offset: f64,
|
||||
// Selection & Anchor Display
|
||||
@@ -366,6 +369,7 @@ impl Default for Prefs {
|
||||
zoom_with_mouse_wheel: false,
|
||||
scrub_numeric_fields: true,
|
||||
trackpad_rotate_view: true,
|
||||
hold_tool_keys: true,
|
||||
paste_offset: 10.0,
|
||||
selection_tolerance: 3.0,
|
||||
object_selection_by_path_only: false,
|
||||
|
||||
@@ -25,6 +25,8 @@ impl Session {
|
||||
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
|
||||
vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision()
|
||||
});
|
||||
// Envelopes map type from the bounds of its glyph outlines, underlines and strikethroughs.
|
||||
vectorcraft_text::register_envelope_ink();
|
||||
// Exports bake Vector Halftone into the dots the renderer samples.
|
||||
vectorcraft_render::effects::addons::halftone::set_art(vectorcraft_render::addons::halftone::art);
|
||||
// OpenType SVG glyphs are drawn as the SVG importer reads their documents.
|
||||
|
||||
+135
-2
@@ -14,11 +14,14 @@
|
||||
//! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named
|
||||
//! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it.
|
||||
//! Attaching the folder installs them again. The Additional Plug-ins Folder preference is not
|
||||
//! copied: it is loaded from where it is.
|
||||
//! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins
|
||||
//! stored there that list permissions (id, module hash, permissions); a stored plug-in whose
|
||||
//! permissions it doesn't grant is skipped with a log record, and stays in the folder.
|
||||
//!
|
||||
//! The files are untrusted input like any other: sizes and counts are capped, a file that does not
|
||||
//! parse is skipped with a log record, and every write is atomic.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -41,6 +44,10 @@ const PROFILE_EXT: &str = "icc";
|
||||
pub const MAX_PLUGIN_STORE: u64 = 512 << 20;
|
||||
/// The extension of a stored plug-in.
|
||||
const PLUGIN_EXT: &str = "wasm";
|
||||
/// The file in the plug-in store that holds the permissions the user granted.
|
||||
const GRANTS_FILE: &str = "grants.json";
|
||||
/// The largest grants file read.
|
||||
const MAX_GRANTS_FILE: u64 = 1 << 20;
|
||||
|
||||
/// The file name (without extension) a stored item called `name` gets: its name made safe for any
|
||||
/// file system, then a hash of the name, so that names that differ only in characters a file name
|
||||
@@ -217,8 +224,10 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Keep the plug-ins this session installs in `dir`, and install the ones already there (a
|
||||
/// module that doesn't load is skipped with a log record).
|
||||
/// module that doesn't load, and one that lists permissions the grants there don't grant it,
|
||||
/// is skipped with a log record).
|
||||
pub fn attach_plugin_store(&mut self, dir: PathBuf) {
|
||||
let grants = stored_grants(&dir);
|
||||
let limits = vectorcraft_plugins::Limits::default();
|
||||
let mut left = MAX_PLUGIN_STORE;
|
||||
for path in list_files(&dir, PLUGIN_EXT) {
|
||||
@@ -231,6 +240,18 @@ impl Session {
|
||||
vectorcraft_plugins::Plugin::load(&bytes, limits.clone()).map_err(|e| e.to_string())
|
||||
});
|
||||
match loaded {
|
||||
// Only this store's grants count: one given elsewhere in the process doesn't let a
|
||||
// module stored here load.
|
||||
Ok(plugin) if plugin.manifest().asks_permissions() => {
|
||||
let m = plugin.manifest();
|
||||
match grants.get(&m.id).filter(|g| g.covers(&m.permissions())) {
|
||||
Some(g) => {
|
||||
vectorcraft_plugins::registry::grant(g.clone());
|
||||
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
|
||||
}
|
||||
None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()),
|
||||
}
|
||||
}
|
||||
Ok(plugin) => {
|
||||
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
|
||||
}
|
||||
@@ -240,6 +261,26 @@ impl Session {
|
||||
self.plugin_dir = Some(dir);
|
||||
}
|
||||
|
||||
/// Whether `plugin` installs without asking the user: it lists no permissions, or its id was
|
||||
/// granted every one it lists, in the grants of the attached plug-in store when there is one,
|
||||
/// else in this process's ([`vectorcraft_plugins::registry::approved`]).
|
||||
pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool {
|
||||
self.plugin_approver()(plugin)
|
||||
}
|
||||
|
||||
/// [`Session::plugin_approved`] for many plug-ins: reads the store's grants once, when it is
|
||||
/// made, and checks each plug-in against them.
|
||||
pub(crate) fn plugin_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> {
|
||||
let grants = self.plugin_dir.as_deref().map(stored_grants);
|
||||
move |plugin| {
|
||||
let m = plugin.manifest();
|
||||
match &grants {
|
||||
Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.permissions())),
|
||||
_ => vectorcraft_plugins::registry::approved(plugin),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the module `bytes` of the plug-in `id` to the store.
|
||||
pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) {
|
||||
if let Some(dir) = &self.plugin_dir {
|
||||
@@ -247,6 +288,46 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record in the store what the user granted plug-in `id` (`None`: nothing), leaving the store's
|
||||
/// other grants as they are; with none left, remove the file. Entries this version can't use
|
||||
/// are written back as they were, except one for `id`. A grants file that is too large, can't
|
||||
/// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded;
|
||||
/// so is one whose new content would be larger than `MAX_GRANTS_FILE`.
|
||||
pub(crate) fn store_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) {
|
||||
let Some(dir) = &self.plugin_dir else { return };
|
||||
let mut file = match read_grants(dir) {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
log::warn!("not updating the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let others = file.other.len();
|
||||
file.other.retain(|entry| entry.get("id").and_then(serde_json::Value::as_str) != Some(id));
|
||||
let changed = match grant {
|
||||
Some(g) => file.grants.insert(id.to_string(), g.clone()).as_ref() != Some(&g),
|
||||
None => file.grants.remove(id).is_some(),
|
||||
} || file.other.len() != others;
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
let mut entries: Vec<serde_json::Value> = file.grants.into_values().filter_map(|g| serde_json::to_value(g).ok()).collect();
|
||||
entries.extend(file.other);
|
||||
if entries.is_empty() {
|
||||
take(dir, GRANTS_FILE);
|
||||
return;
|
||||
}
|
||||
// The file is read back only up to its cap: written compactly when pretty-printed is larger,
|
||||
// and not at all when that is too.
|
||||
let fits = |bytes: &Vec<u8>| bytes.len() as u64 <= MAX_GRANTS_FILE;
|
||||
match serde_json::to_vec_pretty(&entries).ok().filter(fits).or_else(|| serde_json::to_vec(&entries).ok().filter(fits)) {
|
||||
Some(bytes) => {
|
||||
put(dir, GRANTS_FILE, &bytes);
|
||||
}
|
||||
None => log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the plug-in `id` from the store.
|
||||
pub(crate) fn unstore_plugin(&self, id: &str) {
|
||||
if let Some(dir) = &self.plugin_dir {
|
||||
@@ -255,6 +336,58 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the grants file of a plug-in store holds.
|
||||
#[derive(Default)]
|
||||
struct GrantsFile {
|
||||
/// The grants this version can use, by plug-in id.
|
||||
grants: BTreeMap<String, vectorcraft_plugins::permissions::Grant>,
|
||||
/// The entries it can't use (a permission it doesn't know, an invalid id), as they were.
|
||||
other: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// The grants file of the plug-in store `dir`: empty when there is none, an error when it is too
|
||||
/// large, can't be read or doesn't hold a list. An entry that doesn't parse as a grant, or names an
|
||||
/// invalid id, is kept in [`GrantsFile::other`] with a log record.
|
||||
fn read_grants(dir: &Path) -> Result<GrantsFile, String> {
|
||||
let path = dir.join(GRANTS_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(GrantsFile::default());
|
||||
}
|
||||
let entries =
|
||||
read_capped(&path, MAX_GRANTS_FILE).and_then(|bytes| serde_json::from_slice::<Vec<serde_json::Value>>(&bytes).map_err(|e| e.to_string()))?;
|
||||
let mut file = GrantsFile::default();
|
||||
let mut first_error = None;
|
||||
for entry in entries {
|
||||
let why = match serde_json::from_value::<vectorcraft_plugins::permissions::Grant>(entry.clone()) {
|
||||
Ok(g) if vectorcraft_plugins::manifest::valid_id(&g.id) => {
|
||||
file.grants.insert(g.id.clone(), g);
|
||||
continue;
|
||||
}
|
||||
Ok(g) => format!("invalid id `{}`", g.id.escape_debug()),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
first_error.get_or_insert(why);
|
||||
file.other.push(entry);
|
||||
}
|
||||
if let Some(why) = first_error {
|
||||
log::warn!("skipping {} stored plug-in grants in {} (the first: {why})", file.other.len(), path.display());
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// The grants kept in the plug-in store `dir` that this version can use, by plug-in id: none when
|
||||
/// there is no grants file, and none, with a log record, when it is too large, can't be read or
|
||||
/// doesn't hold a list.
|
||||
fn stored_grants(dir: &Path) -> BTreeMap<String, vectorcraft_plugins::permissions::Grant> {
|
||||
match read_grants(dir) {
|
||||
Ok(file) => file.grants,
|
||||
Err(e) => {
|
||||
log::warn!("skipping the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display());
|
||||
BTreeMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a
|
||||
/// built-in preset, at most [`MAX_STORE_FILES`].
|
||||
fn load_presets(dir: &Path) -> Vec<PdfPreset> {
|
||||
|
||||
@@ -3617,16 +3617,18 @@ fn given_targets_are_used_as_given_and_range_style_takes_the_selected_type() {
|
||||
}
|
||||
|
||||
/// #785: commands whose `ids` or `id` the shared check doesn't read (it reads `id` where it stands
|
||||
/// for `ids` of one) refuse one that isn't an object id. They don't act on the selection or the
|
||||
/// current layer instead, and the document and its undo history stay as they are.
|
||||
/// for `ids` of one) refuse one that isn't an object id. They don't act on the selection, the
|
||||
/// current layer or a new object instead, and the document, the selection and the undo history
|
||||
/// stay as they are.
|
||||
#[test]
|
||||
fn malformed_ids_are_refused_by_commands_that_default_to_the_selection() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 80.0, 60.0);
|
||||
let b = rect(&mut s, 140.0, 120.0, 80.0, 60.0);
|
||||
let graph = s.execute("graph.create", &json!({"type": "column", "x": 400, "y": 300, "width": 120, "height": 80, "rows": [[1, 2]]})).unwrap();
|
||||
let text = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "type", "size": 30})).unwrap()["id"].clone();
|
||||
let ids = [json!([a.0, "x"]), json!(3)];
|
||||
let id = [json!("2"), json!("$1.id"), json!(2.5)];
|
||||
let id = [json!("2"), json!("$1.id"), json!(2.5), json!("abc"), json!(true), json!({})];
|
||||
let commands = [
|
||||
("object.blend.make", json!({}), "ids", &ids[..], json!([a.0, b.0])),
|
||||
("object.envelope.makeWithWarp", json!({}), "ids", &ids[..], json!([a.0, b.0])),
|
||||
@@ -3636,6 +3638,26 @@ fn malformed_ids_are_refused_by_commands_that_default_to_the_selection() {
|
||||
("perspective.editText", json!({}), "id", &id[..], json!([a.0])),
|
||||
("layer.clippingMask.toggle", json!({}), "id", &id[..], json!([a.0, b.0])),
|
||||
("layer.newSublayer", json!({}), "parent", &id[..], json!([a.0])),
|
||||
("object.isolate", json!({}), "id", &id[..], json!([a.0])),
|
||||
("graphicStyle.new", json!({"name": "From a"}), "id", &id[..], json!([a.0])),
|
||||
("charStyle.new", json!({"name": "From type"}), "id", &id[..], json!([text])),
|
||||
("paraStyle.new", json!({"name": "From type"}), "id", &id[..], json!([text])),
|
||||
("charStyle.apply", json!({"name": "[Normal Character Style]"}), "id", &id[..], json!([text])),
|
||||
("type.step", json!({"attribute": "size"}), "id", &id[..], json!([text])),
|
||||
("layer.releaseToLayers", json!({"build": true}), "id", &id[..], json!([a.0])),
|
||||
("layer.locate", json!({}), "id", &id[..], json!([a.0])),
|
||||
("transparency.editOpacityMask", json!({}), "id", &id[..], json!([a.0])),
|
||||
("transparency.viewOpacityMask", json!({}), "id", &id[..], json!([a.0])),
|
||||
("links.info", json!({}), "id", &id[..], json!([a.0])),
|
||||
("links.unembed", json!({}), "id", &id[..], json!([a.0])),
|
||||
("links.goTo", json!({}), "id", &id[..], json!([a.0])),
|
||||
("shaper.release", json!({}), "id", &id[..], json!([a.0])),
|
||||
("shaper.expand", json!({}), "id", &id[..], json!([a.0])),
|
||||
("image.info", json!({}), "id", &id[..], json!([a.0])),
|
||||
("symbol.edit", json!({}), "id", &id[..], json!([a.0])),
|
||||
("object.blend.spine.addAnchor", json!({"x": 120, "y": 110}), "id", &id[..], json!([a.0])),
|
||||
("path.curvature", json!({"points": [[0, 0], [50, 40], [100, 0]]}), "id", &id[..], json!([a.0])),
|
||||
("css.export", json!({"unnamed": true}), "id", &id[..], json!([a.0])),
|
||||
];
|
||||
let mut wrong = vec![];
|
||||
for (cmd, params, key, values, selected) in commands {
|
||||
@@ -3645,21 +3667,44 @@ fn malformed_ids_are_refused_by_commands_that_default_to_the_selection() {
|
||||
if let Some(p) = p.as_object_mut() {
|
||||
p.insert(key.into(), bad.clone());
|
||||
}
|
||||
let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let (doc, selection, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), s.doc().unwrap().history.undo.len());
|
||||
let r = s.execute(cmd, &p);
|
||||
let refused = r.as_ref().err().map(|e| e.to_string()).is_some_and(|e| e.contains("object id") || e.contains("step reference"));
|
||||
let same = s.doc().unwrap().doc == doc && s.doc().unwrap().history.undo.len() == undo;
|
||||
let named = |e: &str| e.contains("object id") || e.contains("step reference") || e.contains(&format!("`{key}` must be"));
|
||||
let refused = r.as_ref().err().map(|e| e.to_string()).is_some_and(|e| named(&e));
|
||||
let same = s.doc().unwrap().doc == doc && s.doc().unwrap().selection == selection && s.doc().unwrap().history.undo.len() == undo;
|
||||
if !(refused && same) {
|
||||
wrong.push(format!("{cmd} {p}: {r:?}, document unchanged: {same}"));
|
||||
}
|
||||
if !same {
|
||||
s.execute("edit.undo", &json!({})).ok();
|
||||
let st = s.doc_mut().unwrap();
|
||||
(st.doc, st.selection) = (doc, selection);
|
||||
st.history.undo.truncate(undo);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
|
||||
}
|
||||
|
||||
/// An omitted or null `id` still means the selection, and an object id names that object.
|
||||
/// `effect.apply` with the effect given as `id` (its older key) still applies it to the selection,
|
||||
/// as one undo step.
|
||||
#[test]
|
||||
fn an_omitted_id_still_means_the_selection_and_effect_apply_takes_an_effect_as_id() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 80.0, 60.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
for p in [json!({}), json!({"id": null}), json!({"id": a.0})] {
|
||||
assert_eq!(s.execute("layer.locate", &p).unwrap()["id"], a.0, "{p}");
|
||||
}
|
||||
for p in [json!({"unnamed": true}), json!({"id": null, "unnamed": true}), json!({"id": a.0, "unnamed": true})] {
|
||||
assert_eq!(s.execute("css.export", &p).unwrap()["rules"], 1, "{p}");
|
||||
}
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("effect.apply", &json!({"id": "stylize.dropShadow"})).unwrap();
|
||||
assert_eq!(r["ids"], json!([a.0]));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_anchor_selection_never_replaces_or_partially_changes_selection() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -445,3 +445,470 @@ fn warped_type_in_a_symbol_reopens_unchanged() {
|
||||
let differ = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| before.pixel(x, y) != after.pixel(x, y)).count();
|
||||
assert_eq!(differ, 0, "{differ} pixels changed on reopening");
|
||||
}
|
||||
|
||||
/// The glyph outline bounds of type `id` in document space.
|
||||
fn ink_of(s: &Session, id: NodeId) -> Rect {
|
||||
match node(s, id).kind {
|
||||
NodeKind::Text(t) => t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_box(a: Rect, b: Rect, what: &str) {
|
||||
let d = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(d < 1e-6, "{what}: {a:?} vs {b:?}");
|
||||
}
|
||||
|
||||
/// The box envelope `id` maps its content from.
|
||||
fn src_of(s: &Session, id: NodeId) -> Rect {
|
||||
live::EnvelopeMap::of(&node(s, id)).unwrap().src()
|
||||
}
|
||||
|
||||
/// Envelopes map point type, area type and type in a group from the glyph outline bounds, not
|
||||
/// from the laid-out box (ascent to descent, or the area's frame): with a flat warp the distorted
|
||||
/// type sits where the type was, and Make with Mesh lays its grid over the glyphs.
|
||||
#[test]
|
||||
fn envelopes_map_type_from_its_glyph_outline_bounds() {
|
||||
let mut s = session();
|
||||
let point = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Envelope", "size": 48})).unwrap());
|
||||
let area = id_of(
|
||||
&s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Area type", "size": 36, "area": {"width": 300, "height": 200}})).unwrap(),
|
||||
);
|
||||
let grouped = id_of(&s.execute("text.create", &json!({"x": 450, "y": 200, "text": "Group", "size": 40})).unwrap());
|
||||
sel(&mut s, &[grouped]);
|
||||
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
for (t, target) in [(point, point), (area, area), (grouped, group)] {
|
||||
let (ink, laid) = (ink_of(&s, t), node(&s, t).geometric_bounds().unwrap());
|
||||
assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}");
|
||||
let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(node(&s, target))], Affine::IDENTITY).unwrap();
|
||||
assert_box(plain, ink, "geometric box");
|
||||
assert_box(stroked, ink, "box with strokes (none)");
|
||||
sel(&mut s, &[target]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), ink, "warp");
|
||||
assert_box(node(&s, e).geometric_bounds().unwrap(), ink, "flat warp's bounds");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap());
|
||||
let EnvelopeKind::Mesh { points, .. } = kind(&s, e) else { panic!("mesh") };
|
||||
let grid = points.iter().fold(Rect::from_points(points[0], points[0]), |r, p| r.union_pt(*p));
|
||||
assert_box(grid, ink, "mesh grid");
|
||||
assert_box(src_of(&s, e), ink, "mesh");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shrink Text to Fit lays type out by its size on the page: after the envelope is scaled, it
|
||||
/// maps from the glyphs of the type laid out at its new size, not those measured before.
|
||||
#[test]
|
||||
fn a_scaled_envelope_of_shrunk_type_maps_from_its_new_glyphs() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 2, "area": {"width": 3, "height": 3}})).unwrap());
|
||||
s.edit("shrink", |d, _| {
|
||||
if let NodeKind::Text(tx) = &mut d.node_mut(t).unwrap().kind {
|
||||
tx.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
let text = |s: &Session| match node(s, e).kind {
|
||||
NodeKind::Envelope { content, .. } => match &content[0].kind {
|
||||
NodeKind::Text(tx) => tx.clone(),
|
||||
k => panic!("{k:?}"),
|
||||
},
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let ink = |tx: &vectorcraft_doc::TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).ink_bounds().unwrap();
|
||||
let before = text(&s);
|
||||
assert_box(src_of(&s, e), before.xf.transform_rect_bbox(ink(&before)), "before scaling");
|
||||
s.execute("object.scale", &json!({"sx": 150, "sy": 150, "origin": [100, 100]})).unwrap();
|
||||
let after = text(&s);
|
||||
let fresh = after.xf.transform_rect_bbox(ink(&after));
|
||||
let stale = after.xf.transform_rect_bbox(ink(&before));
|
||||
assert!((fresh.width() - stale.width()).abs() > 1e-3 || (fresh.height() - stale.height()).abs() > 1e-3, "{fresh:?} vs {stale:?}");
|
||||
assert_box(src_of(&s, e), fresh, "after scaling");
|
||||
}
|
||||
|
||||
/// A rotated envelope keeps a frame of its own: shrunk type in it is measured as it is laid out on
|
||||
/// the page, and its box is then taken into the frame.
|
||||
#[test]
|
||||
fn a_rotated_envelope_of_shrunk_type_maps_from_the_glyphs_on_the_page() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 2, "area": {"width": 3, "height": 3}})).unwrap());
|
||||
s.edit("shrink", |d, _| {
|
||||
if let NodeKind::Text(tx) = &mut d.node_mut(t).unwrap().kind {
|
||||
tx.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
s.execute("object.rotate", &json!({"angle": 30, "origin": [100, 100]})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 150, "sy": 150, "origin": [100, 100]})).unwrap();
|
||||
let (frame, tx) = match node(&s, e).kind {
|
||||
NodeKind::Envelope { frame, content, .. } => match &content[0].kind {
|
||||
NodeKind::Text(tx) => (*frame, tx.clone()),
|
||||
k => panic!("{k:?}"),
|
||||
},
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
assert!(frame != Affine::IDENTITY, "a rotated envelope has a frame");
|
||||
let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap();
|
||||
assert_box(src_of(&s, e), (frame.inverse() * tx.xf).transform_rect_bbox(ink), "in the frame");
|
||||
}
|
||||
|
||||
/// The bars of underlined type are part of what it draws, so the envelope maps from them too and
|
||||
/// its bounds take them in.
|
||||
#[test]
|
||||
fn an_envelope_maps_underlined_type_from_its_glyphs_and_bars() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "HH", "size": 100})).unwrap());
|
||||
s.execute("text.setFormat", &json!({"ids": [t.0], "underline": true})).unwrap();
|
||||
let glyphs = ink_of(&s, t);
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
let src = src_of(&s, e);
|
||||
assert!(src.y1 > glyphs.y1 + 1.0, "the underline below the glyphs {glyphs:?} is in the box {src:?}");
|
||||
assert_box(node(&s, e).geometric_bounds().unwrap(), src, "flat warp's bounds");
|
||||
let drawn = vectorcraft_render::effects::expanded_live_group(Some(&s.doc().unwrap().doc), &node(&s, e)).geometric_bounds().unwrap();
|
||||
let off = [drawn.x0 - src.x0, drawn.y0 - src.y0, drawn.x1 - src.x1, drawn.y1 - src.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(off < 0.05, "the drawn art {drawn:?} fills the box {src:?}");
|
||||
}
|
||||
|
||||
/// Undo of Make Envelope gives the type back with its laid-out box, and redo maps it from its
|
||||
/// glyphs again.
|
||||
#[test]
|
||||
fn undo_and_redo_of_an_envelope_of_type_keep_the_glyph_mapping() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Undo", "size": 60})).unwrap());
|
||||
let (ink, laid) = (ink_of(&s, t), node(&s, t).geometric_bounds().unwrap());
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "bulge", "bend": 30})).unwrap());
|
||||
let made = node(&s, e).geometric_bounds().unwrap();
|
||||
assert_box(src_of(&s, e), ink, "made");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(e).is_none());
|
||||
assert_box(node(&s, t).geometric_bounds().unwrap(), laid, "the type after undo");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_box(src_of(&s, e), ink, "redone");
|
||||
assert_box(node(&s, e).geometric_bounds().unwrap(), made, "redone bounds");
|
||||
}
|
||||
|
||||
/// A saved envelope of type reopens drawn by the same rule: from the glyph outline bounds, which
|
||||
/// the file does not store.
|
||||
#[test]
|
||||
fn a_saved_envelope_of_type_reopens_mapped_from_its_glyphs() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Saved", "size": 72})).unwrap());
|
||||
let ink = ink_of(&s, t);
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "flag", "bend": 40})).unwrap());
|
||||
let render = |s: &Session| vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 600, 400, Affine::IDENTITY, &Default::default());
|
||||
let before = render(&s);
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-envelope-ink-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("type.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_box(src_of(&o, e), ink, "reopened");
|
||||
let after = render(&o);
|
||||
let differ = (0..400).flat_map(|y| (0..600).map(move |x| (x, y))).filter(|&(x, y)| before.pixel(x, y) != after.pixel(x, y)).count();
|
||||
assert_eq!(differ, 0, "{differ} pixels changed on reopening");
|
||||
}
|
||||
|
||||
/// The type of envelope `e`'s first content object.
|
||||
fn content_text(s: &Session, e: NodeId) -> vectorcraft_doc::TextObject {
|
||||
match node(s, e).kind {
|
||||
NodeKind::Envelope { content, .. } => match &content[0].kind {
|
||||
NodeKind::Text(tx) => (**tx).clone(),
|
||||
k => panic!("{k:?}"),
|
||||
},
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The box of `tx`'s glyph outlines with text space mapped by `m`, each outline mapped point by point.
|
||||
fn outlines_in(tx: &vectorcraft_doc::TextObject, m: Affine) -> Rect {
|
||||
let path = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).to_bezpath();
|
||||
kurbo::Shape::bounding_box(&(m * path))
|
||||
}
|
||||
|
||||
/// Rotated type maps from the box of its rotated glyph outlines, which is smaller than the box of
|
||||
/// its rotated glyph box; in a rotated envelope whose frame turns by another angle than the type,
|
||||
/// it maps from the outlines taken into the frame.
|
||||
#[test]
|
||||
fn an_envelope_of_rotated_type_maps_from_its_rotated_outlines() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 200, "y": 300, "text": "Rotated", "size": 60})).unwrap());
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("object.rotate", &json!({"angle": 40, "origin": [200, 300]})).unwrap();
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
let tx = content_text(&s, e);
|
||||
let outlines = outlines_in(&tx, tx.xf);
|
||||
let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap();
|
||||
let glyph_box = tx.xf.transform_rect_bbox(ink);
|
||||
assert!(glyph_box.width() - outlines.width() > 1.0 || glyph_box.height() - outlines.height() > 1.0, "{glyph_box:?} vs {outlines:?}");
|
||||
assert_box(src_of(&s, e), outlines, "rotated type");
|
||||
s.execute("object.rotate", &json!({"angle": 15, "origin": [200, 300]})).unwrap();
|
||||
let (frame, tx) = match node(&s, e).kind {
|
||||
NodeKind::Envelope { frame, .. } => (*frame, content_text(&s, e)),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
assert!(frame != Affine::IDENTITY, "a rotated envelope has a frame");
|
||||
let in_frame = frame.inverse() * tx.xf;
|
||||
assert!(in_frame.as_coeffs()[1].abs() > 0.1, "the type is turned against the frame: {in_frame:?}");
|
||||
assert_box(src_of(&s, e), outlines_in(&tx, in_frame), "in the frame");
|
||||
}
|
||||
|
||||
/// Type in perspective maps from the box of its outlines as its projection draws them, which is
|
||||
/// smaller than the projected box of its glyphs. Where the projection can't map a point (past its
|
||||
/// horizon), the point counts where it is, as the renderer draws it.
|
||||
#[test]
|
||||
fn an_envelope_of_type_in_perspective_maps_from_its_projected_outlines() {
|
||||
use vectorcraft_geom::Homography;
|
||||
// Every point of the type is past this one's horizon.
|
||||
let horizon = Homography([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]]);
|
||||
for (h, crosses) in [(Homography([[1.0, 0.3, 0.0], [0.1, 1.0, 0.0], [0.0015, 0.0008, 1.0]]), false), (horizon, true)] {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Perspective", "size": 60})).unwrap());
|
||||
s.edit("perspective", |d, _| {
|
||||
let n = d.node_mut(t).unwrap();
|
||||
n.extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment {
|
||||
projection: Some(h.to_array()),
|
||||
..vectorcraft_doc::PerspectiveAttachment::new("left", 0.0)
|
||||
}));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(node(&s, t).projection().is_some());
|
||||
let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") };
|
||||
let path = tx.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).to_bezpath();
|
||||
let (mut drawn, mut unmapped): (Option<Rect>, usize) = (None, 0);
|
||||
kurbo::flatten(&path, 0.001, |el| {
|
||||
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
|
||||
let q = h.apply(p).unwrap_or_else(|| {
|
||||
unmapped += 1;
|
||||
p
|
||||
});
|
||||
drawn = Some(drawn.map_or(Rect::from_points(q, q), |r| r.union_pt(q)));
|
||||
}
|
||||
});
|
||||
let drawn = drawn.unwrap();
|
||||
assert_eq!(unmapped > 0, crosses, "{unmapped} points past the horizon");
|
||||
if !crosses {
|
||||
let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap();
|
||||
let boxed = h.map_rect_bbox(tx.xf.transform_rect_bbox(ink)).unwrap();
|
||||
assert!(boxed.width() - drawn.width() > 1.0 || boxed.height() - drawn.height() > 1.0, "{boxed:?} vs {drawn:?}");
|
||||
}
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
let src = src_of(&s, e);
|
||||
let off = [src.x0 - drawn.x0, src.y0 - drawn.y0, src.x1 - drawn.x1, src.y1 - drawn.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(off < 0.05, "{src:?} vs {drawn:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type that draws nothing (only spaces) keeps its laid-out box.
|
||||
#[test]
|
||||
fn an_envelope_of_type_that_draws_nothing_maps_from_its_laid_out_box() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": " ", "size": 48})).unwrap());
|
||||
let laid = node(&s, t).geometric_bounds().unwrap();
|
||||
assert_box(live::envelope_content_bounds(&[std::sync::Arc::new(node(&s, t))]).unwrap(), laid, "spaces");
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), laid, "warp");
|
||||
}
|
||||
|
||||
/// The strikethrough bar runs to the last glyph's advance, past its outline: the envelope maps
|
||||
/// from it too.
|
||||
#[test]
|
||||
fn an_envelope_maps_struck_through_type_from_its_glyphs_and_bars() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "HH", "size": 100})).unwrap());
|
||||
let plain = ink_of(&s, t);
|
||||
s.execute("text.setFormat", &json!({"ids": [t.0], "strikethrough": true})).unwrap();
|
||||
let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") };
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let laid = vectorcraft_text::layout(db, &tx);
|
||||
let bars = vectorcraft_text::decorations(&laid, db, &tx);
|
||||
assert!(!bars.is_empty(), "struck-through type has a bar");
|
||||
let bar = tx.xf.transform_rect_bbox(kurbo::Shape::bounding_box(&bars[0].1));
|
||||
assert!(bar.x1 > plain.x1 + 1.0, "the bar {bar:?} reaches past the glyphs {plain:?}");
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), plain.union(bar), "glyphs and bar");
|
||||
}
|
||||
|
||||
/// In a layer, the visible type is measured by its glyphs and hidden type isn't measured; in a
|
||||
/// clip group whose clipping path is type, the envelope maps from that type's glyphs.
|
||||
#[test]
|
||||
fn type_in_layers_and_clip_groups_is_measured_by_its_glyphs() {
|
||||
let mut s = session();
|
||||
let shown = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Shown", "size": 48})).unwrap());
|
||||
let hidden = id_of(&s.execute("text.create", &json!({"x": 100, "y": 500, "text": "Hidden", "size": 48})).unwrap());
|
||||
sel(&mut s, &[hidden]);
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
let ink = ink_of(&s, shown);
|
||||
let layer = s.doc().unwrap().doc.layers[0].clone();
|
||||
assert_box(live::envelope_content_bounds(&[layer]).unwrap(), ink, "layer");
|
||||
|
||||
let mut s = session();
|
||||
let back = rect(&mut s, 50.0, 100.0, 500.0, 200.0);
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Clip", "size": 72})).unwrap());
|
||||
let ink = ink_of(&s, t);
|
||||
sel(&mut s, &[back, t]);
|
||||
let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap());
|
||||
assert!(matches!(node(&s, g).kind, NodeKind::Group { clip: true, .. }));
|
||||
let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(node(&s, g))], Affine::IDENTITY).unwrap();
|
||||
assert_box(plain, ink, "clip group");
|
||||
assert_box(stroked, ink, "clip group with strokes (none)");
|
||||
sel(&mut s, &[g]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), ink, "warp of the clip group");
|
||||
}
|
||||
|
||||
/// Stroked type: the box with strokes reaches past the glyphs as far as the type's strokes reach
|
||||
/// past its laid-out box. In perspective, the glyphs are the projected outlines and the reach is
|
||||
/// that of the strokes past the projected laid-out box.
|
||||
#[test]
|
||||
fn stroked_type_maps_from_its_glyphs_grown_by_its_strokes() {
|
||||
let h = vectorcraft_geom::Homography([[1.0, 0.3, 0.0], [0.1, 1.0, 0.0], [0.0015, 0.0008, 1.0]]);
|
||||
for perspective in [false, true] {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Stroke", "size": 60})).unwrap());
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 6})).unwrap();
|
||||
if perspective {
|
||||
s.edit("perspective", |d, _| {
|
||||
d.node_mut(t).unwrap().extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment {
|
||||
projection: Some(h.to_array()),
|
||||
..vectorcraft_doc::PerspectiveAttachment::new("left", 0.0)
|
||||
}));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let n = node(&s, t);
|
||||
assert_eq!(n.projection().is_some(), perspective);
|
||||
let (v, g) = (n.visual_bounds().unwrap(), n.geometric_bounds().unwrap());
|
||||
assert!(v.x0 < g.x0 - 1.0, "the strokes {v:?} reach past the laid-out box {g:?}");
|
||||
let NodeKind::Text(tx) = &n.kind else { panic!("type") };
|
||||
// The glyph outlines as drawn.
|
||||
let ink = if perspective {
|
||||
let path = tx.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).to_bezpath();
|
||||
let mut drawn: Option<Rect> = None;
|
||||
kurbo::flatten(&path, 0.001, |el| {
|
||||
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
|
||||
let q = h.apply(p).unwrap();
|
||||
drawn = Some(drawn.map_or(Rect::from_points(q, q), |r| r.union_pt(q)));
|
||||
}
|
||||
});
|
||||
drawn.unwrap()
|
||||
} else {
|
||||
ink_of(&s, t)
|
||||
};
|
||||
let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(n.clone())], Affine::IDENTITY).unwrap();
|
||||
let near = |a: Rect, b: Rect, what: &str| {
|
||||
let d = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(d < 0.05, "{what} (perspective {perspective}): {a:?} vs {b:?}");
|
||||
};
|
||||
near(plain, ink, "geometric box");
|
||||
near(stroked, Rect::new(ink.x0 - (g.x0 - v.x0), ink.y0 - (g.y0 - v.y0), ink.x1 + (v.x1 - g.x1), ink.y1 + (v.y1 - g.y1)), "with strokes");
|
||||
}
|
||||
}
|
||||
|
||||
/// Make with Top Object maps type from its glyphs onto the path's outline.
|
||||
#[test]
|
||||
fn a_top_object_envelope_of_type_maps_from_its_glyphs() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Top", "size": 72})).unwrap());
|
||||
let ink = ink_of(&s, t);
|
||||
let top = rect(&mut s, 80.0, 100.0, 300.0, 150.0);
|
||||
sel(&mut s, &[t, top]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap());
|
||||
assert!(matches!(kind(&s, e), EnvelopeKind::TopObject { .. }));
|
||||
assert_box(src_of(&s, e), ink, "top object");
|
||||
}
|
||||
|
||||
/// Type in a symbol instance is measured as before: the envelope maps from the instance's own
|
||||
/// bounds, not from the type's glyphs.
|
||||
#[test]
|
||||
fn an_envelope_of_a_symbol_instance_of_type_maps_from_the_instance_s_bounds() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Symbol", "size": 60})).unwrap());
|
||||
let ink = ink_of(&s, t);
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("symbol.new", &json!({"name": "Type"})).unwrap();
|
||||
let inst = s.doc().unwrap().selection.objects[0];
|
||||
assert!(matches!(node(&s, inst).kind, NodeKind::SymbolInstance { .. }));
|
||||
let own = node(&s, inst).geometric_bounds().unwrap();
|
||||
assert!((own.height() - ink.height()).abs() > 1.0, "{own:?} vs {ink:?}");
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), own, "symbol instance");
|
||||
}
|
||||
|
||||
/// Type inside a blend or a repeat is measured as before: the envelope maps from the object's own
|
||||
/// bounds, not from the type's glyphs.
|
||||
#[test]
|
||||
fn an_envelope_of_a_blend_or_repeat_of_type_maps_from_its_own_bounds() {
|
||||
for repeat in [false, true] {
|
||||
let mut s = session();
|
||||
let a = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Blend", "size": 60})).unwrap());
|
||||
let mut parts = vec![a];
|
||||
if !repeat {
|
||||
parts.push(id_of(&s.execute("text.create", &json!({"x": 300, "y": 400, "text": "Blend", "size": 60})).unwrap()));
|
||||
}
|
||||
let ink = parts.iter().map(|t| ink_of(&s, *t)).reduce(|a, b| a.union(b)).unwrap();
|
||||
sel(&mut s, &parts);
|
||||
let made = if repeat { "object.repeat.grid" } else { "object.blend.make" };
|
||||
let holder = id_of(&s.execute(made, &json!({})).unwrap());
|
||||
let own = node(&s, holder).geometric_bounds().unwrap();
|
||||
let measured = live::envelope_content_bounds(&[std::sync::Arc::new(node(&s, holder))]).unwrap();
|
||||
assert_box(measured, own, made);
|
||||
assert!((own.height() - ink.height()).abs() > 1.0 || (own.width() - ink.width()).abs() > 1.0, "{made}: {own:?} vs {ink:?}");
|
||||
sel(&mut s, &[holder]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), own, made);
|
||||
}
|
||||
}
|
||||
|
||||
/// An inline graphic counts by its box: the envelope maps from the glyph outlines and the box of
|
||||
/// the graphic, which reaches past the glyphs.
|
||||
#[test]
|
||||
fn an_envelope_maps_type_with_an_inline_graphic_from_its_glyphs_and_the_graphic() {
|
||||
let mut s = session();
|
||||
s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": 40, "height": 40})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Dot"})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "xx", "size": 40})).unwrap());
|
||||
s.execute("text.insertInline", &json!({"id": t.0, "at": 1, "symbol": "Dot"})).unwrap();
|
||||
let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") };
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx);
|
||||
let [inline] = laid.inlines.as_slice() else { panic!("one inline graphic: {}", laid.inlines.len()) };
|
||||
let graphic = tx.xf.transform_rect_bbox(inline.bounds);
|
||||
let glyphs = outlines_in(&tx, tx.xf);
|
||||
assert!(graphic.y0 < glyphs.y0 - 1.0, "the graphic {graphic:?} reaches above the glyphs {glyphs:?}");
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), glyphs.union(graphic), "glyphs and graphic");
|
||||
}
|
||||
|
||||
/// Reset with Mesh without Maintain Shape lays its flat grid over the glyphs.
|
||||
#[test]
|
||||
fn reset_with_mesh_lays_a_flat_grid_over_the_glyphs() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Reset", "size": 72})).unwrap());
|
||||
let ink = ink_of(&s, t);
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "bulge", "bend": 40})).unwrap());
|
||||
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 3, "maintainShape": false})).unwrap();
|
||||
let EnvelopeKind::Mesh { points, .. } = kind(&s, e) else { panic!("mesh") };
|
||||
let grid = points.iter().fold(Rect::from_points(points[0], points[0]), |r, p| r.union_pt(*p));
|
||||
assert_box(grid, ink, "flat grid");
|
||||
}
|
||||
|
||||
@@ -480,6 +480,151 @@ fn a_missing_link_renders_its_preview_and_relinks() {
|
||||
assert!(s.execute("links.relink", &json!({"ids": [id.0]})).is_err(), "path or folder");
|
||||
}
|
||||
|
||||
/// The path of the file image `id` links to.
|
||||
fn link_path(s: &Session, id: NodeId) -> String {
|
||||
image(s, id).link.map(|l| l.path).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Replace: All Instances relinks every image linked to the same file as the one given, in one
|
||||
/// undo step; Selected Instances (the default) only the one given.
|
||||
#[test]
|
||||
fn relink_all_instances_takes_every_image_of_the_same_file() {
|
||||
let dir = Folder::new("relink-all");
|
||||
let (pic, other) = (dir.file("photo.png"), dir.file("other.png"));
|
||||
write(&pic, &png(60, 30, RED));
|
||||
write(&other, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let (a, b, c) = (place(&mut s, &pic), place(&mut s, &pic), place(&mut s, &other));
|
||||
let blue = dir.file("new/blue.png");
|
||||
write(&blue, &png(60, 30, BLUE));
|
||||
let r = s.execute("links.relink", &json!({"ids": [a.0], "path": blue, "scope": "selected"})).unwrap();
|
||||
assert_eq!(r, json!({"relinked": [a.0], "notFound": []}));
|
||||
assert_eq!(link_path(&s, b), pic, "selected instances: the other copy keeps its file");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let r = s.execute("links.relink", &json!({"ids": [a.0], "path": blue, "scope": "all"})).unwrap();
|
||||
assert_eq!(r, json!({"relinked": [a.0, b.0], "notFound": []}));
|
||||
assert_eq!((link_path(&s, a), link_path(&s, b), link_path(&s, c)), (blue.clone(), blue.clone(), other.clone()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((link_path(&s, a), link_path(&s, b)), (pic.clone(), pic.clone()), "one undo step");
|
||||
// The selection stands in for ids.
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
let r = s.execute("links.relink", &json!({"path": blue, "scope": "all"})).unwrap();
|
||||
assert_eq!(r["relinked"], json!([b.0, a.0]));
|
||||
}
|
||||
|
||||
/// Replace: Missing Links from This Folder relinks the given links to the chosen file, then every
|
||||
/// other missing link to the file of its name in that file's folder, in one undo step; names not
|
||||
/// there are listed in notFound and stay missing.
|
||||
#[test]
|
||||
fn relink_missing_links_from_this_folder_finds_the_others_by_name() {
|
||||
let dir = Folder::new("relink-missing-folder");
|
||||
let names = ["a.png", "b.png", "c.png", "d.png"];
|
||||
let mut s = session();
|
||||
let ids: Vec<NodeId> = names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
write(&dir.file(n), &png(60, 30, RED));
|
||||
place(&mut s, &dir.file(n))
|
||||
})
|
||||
.collect();
|
||||
let doc_path = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &doc_path);
|
||||
// d.png stays where it was: it isn't missing.
|
||||
for n in &names[..3] {
|
||||
std::fs::remove_file(dir.file(n)).unwrap();
|
||||
}
|
||||
open(&mut s, &doc_path);
|
||||
let found = dir.file("found/a new name.png");
|
||||
write(&found, &png(60, 30, BLUE));
|
||||
write(&dir.file("found/b.png"), &png(60, 30, BLUE));
|
||||
write(&dir.file("found/d.png"), &png(60, 30, BLUE));
|
||||
let r = s.execute("links.relink", &json!({"ids": [ids[0].0], "path": found, "scope": "missingInFolder"})).unwrap();
|
||||
assert_eq!(r, json!({"relinked": [ids[0].0, ids[1].0], "notFound": ["c.png"]}));
|
||||
let b_found = dir.0.join("found").join("b.png").to_string_lossy().into_owned();
|
||||
assert_eq!((link_path(&s, ids[0]), link_path(&s, ids[1])), (found.clone(), b_found));
|
||||
assert_eq!((link_path(&s, ids[2]), link_path(&s, ids[3])), (dir.file("c.png"), dir.file("d.png")));
|
||||
let c = s.execute("links.check", &json!({})).unwrap();
|
||||
assert_eq!(c["missing"], 1, "{c}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("links.check", &json!({})).unwrap()["missing"], 3, "one undo step");
|
||||
}
|
||||
|
||||
/// Replace: Each Link Separately takes one file for each link, in one undo step.
|
||||
#[test]
|
||||
fn relink_each_link_separately_takes_a_file_for_each() {
|
||||
let dir = Folder::new("relink-each");
|
||||
let (one, two) = (dir.file("one.png"), dir.file("two.png"));
|
||||
write(&one, &png(60, 30, RED));
|
||||
write(&two, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let (a, b, c) = (place(&mut s, &one), place(&mut s, &one), place(&mut s, &two));
|
||||
let (x, y) = (dir.file("new/x.png"), dir.file("new/y.png"));
|
||||
write(&x, &png(60, 30, BLUE));
|
||||
write(&y, &png(30, 30, BLUE));
|
||||
let paths = json!([{"ids": [a.0, b.0], "path": x}, {"ids": [c.0], "path": y}]);
|
||||
let r = s.execute("links.relink", &json!({"scope": "each", "paths": paths})).unwrap();
|
||||
assert_eq!(r, json!({"relinked": [a.0, b.0, c.0], "notFound": []}));
|
||||
assert_eq!((link_path(&s, a), link_path(&s, b), link_path(&s, c)), (x.clone(), x.clone(), y.clone()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((link_path(&s, a), link_path(&s, c)), (one.clone(), two.clone()), "one undo step");
|
||||
// Two entries naming the same file read it once.
|
||||
let paths = json!([{"ids": [a.0], "path": y}, {"ids": [c.0], "path": y}]);
|
||||
let r = s.execute("links.relink", &json!({"scope": "each", "paths": paths})).unwrap();
|
||||
assert_eq!(r["relinked"], json!([a.0, c.0]));
|
||||
}
|
||||
|
||||
/// With copy, a file that can't be shown stops the call before any file is copied into the
|
||||
/// document's folder.
|
||||
#[test]
|
||||
fn relink_each_with_copy_copies_nothing_when_a_file_cant_be_read() {
|
||||
let dir = Folder::new("relink-each-copy");
|
||||
let (one, two) = (dir.file("doc/one.png"), dir.file("doc/two.png"));
|
||||
write(&one, &png(60, 30, RED));
|
||||
write(&two, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let (a, c) = (place(&mut s, &one), place(&mut s, &two));
|
||||
save(&mut s, &dir.file("doc/poster.vectorcraft"));
|
||||
let (x, bad) = (dir.file("new/x.png"), dir.file("new/broken.png"));
|
||||
write(&x, &png(60, 30, BLUE));
|
||||
write(&bad, b"not an image");
|
||||
let paths = json!([{"ids": [a.0], "path": x}, {"ids": [c.0], "path": bad}]);
|
||||
assert!(s.execute("links.relink", &json!({"scope": "each", "paths": paths, "copy": true})).is_err());
|
||||
assert!(!Path::new(&dir.file("doc/x.png")).exists(), "no copy is left behind");
|
||||
assert_eq!(link_path(&s, a), one);
|
||||
}
|
||||
|
||||
/// A scope or paths that doesn't fit is refused, and nothing is relinked.
|
||||
#[test]
|
||||
fn relink_refuses_scopes_and_paths_that_dont_fit() {
|
||||
let dir = Folder::new("relink-bad-scope");
|
||||
let pic = dir.file("photo.png");
|
||||
write(&pic, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &pic);
|
||||
let blue = dir.file("blue.png");
|
||||
write(&blue, &png(60, 30, BLUE));
|
||||
let bad = [
|
||||
json!({"ids": [a.0], "path": blue, "scope": "everything"}),
|
||||
json!({"ids": [a.0], "path": blue, "scope": 3}),
|
||||
json!({"scope": "each"}),
|
||||
json!({"scope": "each", "paths": []}),
|
||||
json!({"scope": "each", "paths": [{"ids": [a.0]}]}),
|
||||
json!({"scope": "each", "paths": [{"path": blue}]}),
|
||||
json!({"scope": "each", "paths": [{"ids": ["one"], "path": blue}]}),
|
||||
json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}, {"ids": [a.0], "path": pic}]}),
|
||||
json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}], "path": blue}),
|
||||
json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}], "ids": [a.0]}),
|
||||
json!({"scope": "all", "paths": [{"ids": [a.0], "path": blue}]}),
|
||||
json!({"ids": [a.0], "paths": [{"ids": [a.0], "path": blue}]}),
|
||||
json!({"ids": [a.0], "folder": dir.0.to_string_lossy(), "scope": "missingInFolder"}),
|
||||
json!({"ids": [a.0], "scope": "missingInFolder"}),
|
||||
];
|
||||
for p in bad {
|
||||
assert!(s.execute("links.relink", &p).is_err(), "{p}");
|
||||
assert_eq!(link_path(&s, a), pic, "{p} relinks nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// A link stored as a classic Mac OS path (`Disk:Folder:file`, as older `.ai` files keep them) is
|
||||
/// named by its last component: the Links panel shows that name and its folder, Relink to Folder
|
||||
/// looks for that name, and the document's own folder is searched for it. The stored path is kept.
|
||||
|
||||
@@ -488,6 +488,35 @@ fn relink_points_a_placed_document_at_another_file() {
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
|
||||
}
|
||||
|
||||
/// Missing Links from This Folder finds a missing placed document by name, except with copy, which
|
||||
/// takes images only: the placed document then stays as it is.
|
||||
#[test]
|
||||
fn relink_missing_in_folder_finds_placed_documents_except_with_copy() {
|
||||
let dir = Folder::new("placed-missing-folder");
|
||||
let (doc_file, pic) = (dir.file("art/a.vectorcraft"), dir.file("art/photo.png"));
|
||||
write(&pic, &crate::tests_links::png(60, 30, RED));
|
||||
source(&doc_file, 100.0, 50.0, RED, 0);
|
||||
let mut s = session();
|
||||
let (placed_id, image_id) = (place(&mut s, &doc_file), place(&mut s, &pic));
|
||||
let doc_path = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &doc_path);
|
||||
std::fs::remove_file(&doc_file).unwrap();
|
||||
std::fs::remove_file(&pic).unwrap();
|
||||
open(&mut s, &doc_path);
|
||||
let found = dir.0.join("found");
|
||||
let chosen = found.join("renamed.png").to_string_lossy().into_owned();
|
||||
write(&chosen, &crate::tests_links::png(60, 30, BLUE));
|
||||
source(&found.join("a.vectorcraft").to_string_lossy(), 100.0, 50.0, BLUE, 0);
|
||||
let p = json!({"ids": [image_id.0], "path": chosen, "scope": "missingInFolder", "copy": true});
|
||||
let r = s.execute("links.relink", &p).unwrap();
|
||||
assert_eq!(r["relinked"], json!([image_id.0]), "{r}");
|
||||
assert_eq!(placed(&s, placed_id).link.path, doc_file, "copy leaves the placed document");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let p = json!({"ids": [image_id.0], "path": chosen, "scope": "missingInFolder"});
|
||||
let r = s.execute("links.relink", &p).unwrap();
|
||||
assert_eq!(r["relinked"], json!([image_id.0, placed_id.0]), "{r}");
|
||||
}
|
||||
|
||||
/// A file made from scratch at `path` in `format` (`pdf`, or `ai`: a PDF-compatible .ai): one
|
||||
/// 100×50 page per color of `pages`, filled with it (`extra` more rectangles on each change the
|
||||
/// file's size).
|
||||
|
||||
@@ -523,6 +523,301 @@ fn invalid_install_flags_are_refused() {
|
||||
assert!(vectorcraft_plugins::registry::get(light).is_none());
|
||||
}
|
||||
|
||||
/// A test filter `id` (named "Test `id`") whose manifest lists `permissions` (a JSON list) and that
|
||||
/// returns `output`.
|
||||
fn asking(id: &str, permissions: &str, output: &Value) -> Vec<u8> {
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"2.1","kind":"filter","permissions":{permissions}}}"#);
|
||||
wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap()
|
||||
}
|
||||
|
||||
/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`),
|
||||
/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's
|
||||
/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same
|
||||
/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the
|
||||
/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies.
|
||||
#[test]
|
||||
fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
|
||||
let mut s = session();
|
||||
let id = "test.engine.permissions.install";
|
||||
let empty = json!({"objects": []});
|
||||
let data = |perms: &str| vectorcraft_format::base64_encode(&asking(id, perms, &empty));
|
||||
let reads = data(r#"["doc.read"]"#);
|
||||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": reads}));
|
||||
assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read"])));
|
||||
let hash = vectorcraft_plugins::permissions::sha256_hex(&asking(id, r#"["doc.read"]"#, &empty));
|
||||
assert_eq!(inspected["hash"], hash);
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing");
|
||||
// Refused without a grant that holds every permission it lists, naming them.
|
||||
for (grant, says) in [
|
||||
(Value::Null, "asks for the permissions doc.read"),
|
||||
(json!([]), "asks for the permissions doc.read"),
|
||||
(json!(["doc.write"]), "asks for the permissions doc.read"),
|
||||
(json!(["doc.read", "net.fetch"]), "unknown permission `net.fetch`"),
|
||||
(json!("doc.read"), "`grant` must be a list"),
|
||||
(json!([1]), "`grant` must be a list"),
|
||||
] {
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": grant})).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "grant {grant}: {e}");
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with grant {grant}");
|
||||
}
|
||||
// `hash` pins the module the user was shown.
|
||||
for wrong in [json!("0".repeat(64)), json!(1)] {
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": ["doc.read"], "hash": wrong})).unwrap_err().to_string();
|
||||
assert!(e.contains("SHA-256 `hash`"), "hash {wrong}: {e}");
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with hash {wrong}");
|
||||
}
|
||||
let installed = run(&mut s, "plugin.install", json!({"dataBase64": reads, "grant": ["doc.read"], "hash": hash}));
|
||||
assert_eq!(installed["permissions"], json!(["doc.read"]));
|
||||
let g = vectorcraft_plugins::registry::granted(id).unwrap();
|
||||
assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..]));
|
||||
// Replaced with another module that lists the same permissions: no new grant needed, and the
|
||||
// grant now records the new module's hash.
|
||||
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false);
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}));
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other));
|
||||
// One that asks for more is refused until it is granted all of them.
|
||||
let more = data(r#"["doc.read","doc.write"]"#);
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": more}))["needsApproval"], true);
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": more})).unwrap_err().to_string();
|
||||
assert!(e.contains("doc.read, doc.write") && e.contains("`grant`"), "{e}");
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().manifest().permissions().len(), 1, "the one installed stays");
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"]}));
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().permissions.len(), 2, "only what it lists is recorded");
|
||||
// Removed, it is asked again.
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
assert!(vectorcraft_plugins::registry::granted(id).is_none());
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsApproval"], true);
|
||||
// A plug-in without `permissions` installs as before, with its kind's.
|
||||
let plain = install(&mut s, DESATURATE);
|
||||
assert_eq!(plain["permissions"], json!(["doc.read", "doc.write", "state"]));
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false);
|
||||
}
|
||||
|
||||
/// The host enforces what a filter was granted: without `doc.write` an output that changes the
|
||||
/// document is refused and nothing changes, without `doc.read` it is given no objects, and without
|
||||
/// `state` it is given no data kept with the document and may not write any.
|
||||
#[test]
|
||||
fn a_filter_does_only_what_its_permissions_allow() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 10.0, "#ff0000");
|
||||
let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]}));
|
||||
let grant = |s: &mut Session, wasm: &[u8], perms: Value| {
|
||||
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms}));
|
||||
};
|
||||
// Denied `doc.write`: renaming the object is refused, and the document and history stay.
|
||||
let reader = "test.engine.permissions.reader";
|
||||
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0, "name": "Changed"}]})), json!(["doc.read"]));
|
||||
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let e = filter(&mut s, reader).unwrap_err().to_string();
|
||||
assert!(e.contains("wasn't granted `doc.write`"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
// Deleting it (leaving it out of the output) and adding objects are changes too.
|
||||
for out in [json!({"objects": []}), json!({"objects": [{"id": id.0}, {"path": triangle()}]})] {
|
||||
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &out), json!(["doc.read"]));
|
||||
assert!(filter(&mut s, reader).is_err(), "{out}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{out}");
|
||||
}
|
||||
// Returning the object unchanged changes nothing, so it may.
|
||||
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0}]})), json!(["doc.read"]));
|
||||
filter(&mut s, reader).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.layers, before.layers);
|
||||
// Granted `doc.write` alone: it is given no objects, so its triangle is added beside the
|
||||
// rectangle rather than replacing it.
|
||||
let writer = "test.engine.permissions.writer";
|
||||
let manifest = format!(r#"{{"id":"{writer}","name":"W","kind":"filter","permissions":["doc.write"]}}"#);
|
||||
let added = json!({"objects": [{"path": triangle(), "name": "Given nothing"}]});
|
||||
let src = tpl::when_given(&manifest, tpl::Given::Input, r#"{"objects":[]}"#, &added.to_string());
|
||||
grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.write"]));
|
||||
let made = filter(&mut s, writer).unwrap();
|
||||
let new = NodeId(made["ids"][0].as_u64().unwrap());
|
||||
assert_eq!(node(&s, new).name.as_deref(), Some("Given nothing"));
|
||||
assert!(s.doc().unwrap().doc.node(id).is_some(), "the rectangle it wasn't given stays");
|
||||
// Without `state`: kept data is not handed over, and writing some is refused.
|
||||
run(
|
||||
&mut s,
|
||||
"plugin.install",
|
||||
json!({"dataBase64": vectorcraft_format::base64_encode(&asking("test.engine.permissions.keeper", "null", &json!({"objects": [{"id": id.0}], "documentData": {"pen": 1}})))}),
|
||||
);
|
||||
filter(&mut s, "test.engine.permissions.keeper").unwrap();
|
||||
let stateless = "test.engine.permissions.keeper";
|
||||
let manifest = format!(r#"{{"id":"{stateless}","name":"K","kind":"filter","permissions":["doc.read","doc.write"]}}"#);
|
||||
let src =
|
||||
tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":null"#, &json!({"objects": [{"id": id.0, "name": "No data"}]}).to_string());
|
||||
grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.read", "doc.write"]));
|
||||
filter(&mut s, stateless).unwrap();
|
||||
assert_eq!(node(&s, id).name.as_deref(), Some("No data"), "given no kept data");
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
grant(
|
||||
&mut s,
|
||||
&asking(stateless, r#"["doc.read","doc.write"]"#, &json!({"objects": [{"id": id.0}], "documentData": {"pen": 2}})),
|
||||
json!(["doc.read", "doc.write"]),
|
||||
);
|
||||
let e = filter(&mut s, stateless).unwrap_err().to_string();
|
||||
assert!(e.contains("wasn't granted `state`"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1})));
|
||||
}
|
||||
|
||||
/// What the user granted is kept in the plug-in store with the module: the next session installs
|
||||
/// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is
|
||||
/// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder
|
||||
/// until it is granted on its own.
|
||||
#[test]
|
||||
fn granted_permissions_are_kept_and_folders_skip_the_rest() {
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-grants");
|
||||
let id = "test.engine.permissions.stored";
|
||||
let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||||
assert!(folder.0.join("grants.json").is_file());
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store, as granted");
|
||||
// Only a store's own grants count: the same module in another store without a grant is
|
||||
// skipped, and a grant given elsewhere is not written to that store.
|
||||
let other = crate::tests_links::Folder::new("plugin-store-grants-other");
|
||||
std::fs::write(other.0.join("copy.wasm"), &wasm).unwrap();
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
let mut elsewhere = session();
|
||||
elsewhere.attach_plugin_store(other.0.clone());
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "granted in another store only");
|
||||
let e = elsewhere.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap_err().to_string();
|
||||
assert!(e.contains("`grant`"), "a grant kept in another store doesn't count here: {e}");
|
||||
let lone = "test.engine.permissions.lone";
|
||||
let lone_wasm = asking(lone, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
run(&mut elsewhere, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&lone_wasm), "grant": ["doc.read"]}));
|
||||
let kept = std::fs::read_to_string(other.0.join("grants.json")).unwrap();
|
||||
assert!(kept.contains(lone) && !kept.contains(id), "{kept}");
|
||||
run(&mut elsewhere, "plugin.remove", json!({"id": lone}));
|
||||
assert!(!other.0.join("grants.json").exists(), "the last grant removed takes the file with it");
|
||||
// Without the grant it is skipped, and the file stays.
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
std::fs::remove_file(folder.0.join("grants.json")).unwrap();
|
||||
let mut third = session();
|
||||
third.attach_plugin_store(folder.0.clone());
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "nobody granted it");
|
||||
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "the module stays in the store");
|
||||
|
||||
// The Additional Plug-ins Folder skips it with a note until it is granted.
|
||||
let dir = crate::tests_links::Folder::new("plugin-folder-grants");
|
||||
std::fs::write(dir.0.join("asks.wasm"), &wasm).unwrap();
|
||||
let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()}));
|
||||
assert_eq!(r["loaded"], json!([]));
|
||||
let why = r["failed"][0]["error"].as_str().unwrap();
|
||||
assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}");
|
||||
run(&mut third, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||||
let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()}));
|
||||
assert_eq!(r["loaded"], json!([id]));
|
||||
run(&mut third, "plugin.remove", json!({"id": id}));
|
||||
// With a store attached, only that store's grants load it from the folder: a grant given in a
|
||||
// session without a store doesn't count.
|
||||
let mut storeless = session();
|
||||
run(&mut storeless, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
assert!(vectorcraft_plugins::registry::granted(id).is_some(), "granted in the process");
|
||||
let empty = crate::tests_links::Folder::new("plugin-store-grants-empty");
|
||||
let mut scoped = session();
|
||||
scoped.attach_plugin_store(empty.0.clone());
|
||||
let r = run(&mut scoped, "plugin.reload", json!({"path": dir.0.display().to_string()}));
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
assert_eq!(r["loaded"], json!([]), "a grant outside this store doesn't load it");
|
||||
let why = r["failed"][0]["error"].as_str().unwrap();
|
||||
assert!(why.contains("needs approval"), "{why}");
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "not installed");
|
||||
// The preference's folder is loaded again for a session whose store holds other grants.
|
||||
let pref_dir = crate::tests_links::Folder::new("plugin-folder-pref-grants");
|
||||
std::fs::write(pref_dir.0.join("asks.wasm"), &wasm).unwrap();
|
||||
let pref = json!({"key": "pluginsFolder", "value": pref_dir.0.display().to_string()});
|
||||
run(&mut scoped, "prefs.set", pref.clone());
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "nothing granted in this store");
|
||||
let granting = crate::tests_links::Folder::new("plugin-store-grants-pref");
|
||||
let mut granted = session();
|
||||
granted.attach_plugin_store(granting.0.clone());
|
||||
run(&mut granted, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
run(&mut granted, "prefs.set", pref);
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_some(), "loaded under this store's grant");
|
||||
run(&mut granted, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A grants file entry this version can't use (a permission it doesn't know, an invalid id) is
|
||||
/// skipped without dropping the other grants, and recording a grant writes it back as it was,
|
||||
/// except an entry for the plug-in being granted or removed. A grants file that doesn't parse as a
|
||||
/// list is never overwritten.
|
||||
#[test]
|
||||
fn grants_this_version_cant_read_are_kept() {
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-grants-unknown");
|
||||
let kept = "test.engine.permissions.kept";
|
||||
let added = "test.engine.permissions.added";
|
||||
let later = "test.engine.permissions.later";
|
||||
let empty = json!({"objects": []});
|
||||
let kept_wasm = asking(kept, r#"["doc.read"]"#, &empty);
|
||||
let added_wasm = asking(added, r#"["doc.read"]"#, &empty);
|
||||
let later_wasm = asking(later, r#"["doc.read"]"#, &empty);
|
||||
let future = json!({"id": later, "hash": "00", "permissions": ["doc.read", "ui.panel"]});
|
||||
let invalid = json!({"id": "bad id/", "hash": "00", "permissions": ["doc.read"]});
|
||||
let grant = json!({"id": kept, "hash": vectorcraft_plugins::permissions::sha256_hex(&kept_wasm), "permissions": ["doc.read"]});
|
||||
let grants = folder.0.join("grants.json");
|
||||
std::fs::write(&grants, serde_json::to_vec(&json!([future, grant, invalid])).unwrap()).unwrap();
|
||||
std::fs::write(folder.0.join("kept.wasm"), &kept_wasm).unwrap();
|
||||
let entries = || serde_json::from_slice::<Vec<Value>>(&std::fs::read(&grants).unwrap()).unwrap();
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
assert!(vectorcraft_plugins::registry::get(kept).is_some(), "the entries this version can't read don't drop the grant next to them");
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
|
||||
let now = entries();
|
||||
assert_eq!(now.len(), 4, "{now:?}");
|
||||
assert!(now.contains(&future) && now.contains(&invalid), "written back as they were: {now:?}");
|
||||
assert!(now.iter().any(|e| e["id"] == added), "{now:?}");
|
||||
run(&mut s, "plugin.remove", json!({"id": added}));
|
||||
assert_eq!(entries().len(), 3);
|
||||
// Granting the plug-in an entry names replaces that entry.
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&later_wasm), "grant": ["doc.read"]}));
|
||||
let now = entries();
|
||||
assert!(!now.contains(&future), "{now:?}");
|
||||
assert_eq!(now.iter().filter(|e| e["id"] == later).count(), 1, "{now:?}");
|
||||
run(&mut s, "plugin.remove", json!({"id": later}));
|
||||
run(&mut s, "plugin.remove", json!({"id": kept}));
|
||||
assert_eq!(entries(), vec![invalid.clone()], "the last usable grant removed leaves the entry it can't read");
|
||||
|
||||
// A file that doesn't hold a list is left as it is: installing still works in this session.
|
||||
for broken in [&b"[{\"id\": "[..], &b"{\"id\": \"x\"}"[..]] {
|
||||
std::fs::write(&grants, broken).unwrap();
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
|
||||
assert!(vectorcraft_plugins::registry::get(added).is_some());
|
||||
assert_eq!(std::fs::read(&grants).unwrap(), broken, "not overwritten");
|
||||
run(&mut s, "plugin.remove", json!({"id": added}));
|
||||
assert_eq!(std::fs::read(&grants).unwrap(), broken, "not overwritten by a removal either");
|
||||
}
|
||||
|
||||
// The file stays within the 1 MiB it is read up to: compact when pretty-printing would pass it,
|
||||
// and left as it is when even compact would.
|
||||
let cap = 1 << 20;
|
||||
let many: Vec<Value> = (0..14_000).map(|i| json!({"id": format!("x.{i}"), "hash": "00", "permissions": ["ui.panel"]})).collect();
|
||||
let compact = serde_json::to_vec(&many).unwrap();
|
||||
assert!(compact.len() < cap && serde_json::to_vec_pretty(&many).unwrap().len() > cap);
|
||||
std::fs::write(&grants, &compact).unwrap();
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
|
||||
let written = std::fs::read(&grants).unwrap();
|
||||
assert!(written.len() <= cap, "{}", written.len());
|
||||
assert_eq!(entries().len(), many.len() + 1);
|
||||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm)}));
|
||||
assert_eq!(inspected["needsApproval"], json!(false), "the compact file is read back");
|
||||
run(&mut s, "plugin.remove", json!({"id": added}));
|
||||
let full = serde_json::to_vec(&json!([{"id": "x.full", "hash": "0".repeat(cap - 100), "permissions": ["ui.panel"]}])).unwrap();
|
||||
std::fs::write(&grants, &full).unwrap();
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
|
||||
assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap");
|
||||
run(&mut s, "plugin.remove", json!({"id": added}));
|
||||
}
|
||||
|
||||
/// The user agreed once: a heavy filter kept in the plug-in store is installed again at the next
|
||||
/// start without asking, with its budget.
|
||||
#[test]
|
||||
@@ -616,6 +911,35 @@ fn a_filter_run_made_elsewhere_keeps_its_document_data() {
|
||||
assert_eq!(s.doc().unwrap().doc.plugin_data(id), Some(&json!({"pens": 2})), "undone with its objects");
|
||||
}
|
||||
|
||||
/// A plug-in of API level 2 that uses the host imports lists `progress`, which the user grants like
|
||||
/// any permission; a filter run made elsewhere then reports its progress, as a share, to the
|
||||
/// progress the caller shows, and sees the caller's Cancel.
|
||||
#[test]
|
||||
fn a_level_2_filter_installs_with_progress_granted_and_reports_to_the_caller() {
|
||||
use crate::cmd::fileio::progress::{self, Progress};
|
||||
use crate::cmd::plugin::FilterRun;
|
||||
let mut s = session();
|
||||
let id = "test.engine.level2";
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}}"#);
|
||||
let wasm = wat::parse_str(tpl::cooperating(&manifest, 1000, r#"{"objects":[]}"#, r#"{"objects":[]}"#)).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&wasm);
|
||||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data}));
|
||||
assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read", "doc.write", "progress"])));
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read", "doc.write"]})).unwrap_err().to_string();
|
||||
assert!(e.contains("asks for the permissions doc.read, doc.write, progress"), "{e}");
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write", "progress"]}));
|
||||
let shown = Progress::new();
|
||||
let r = FilterRun::prepare(&s, &json!({"id": id})).unwrap();
|
||||
assert!(progress::scope(&shown, || r.run()).is_ok());
|
||||
assert_eq!((shown.counts(), shown.counts_share()), ((999, 1000), true));
|
||||
// Cancelled, it stops on its first step, and what it returns then is discarded.
|
||||
shown.cancel();
|
||||
let cancelled = progress::scope(&shown, || r.run());
|
||||
assert!(matches!(cancelled, Err(EngineError::Cancelled)), "{cancelled:?}");
|
||||
assert_eq!(shown.counts(), (0, 1000));
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between
|
||||
/// two slices of its budget and changes nothing.
|
||||
#[test]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! document's colour mode.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
|
||||
use crate::*;
|
||||
|
||||
@@ -125,6 +125,275 @@ fn add_used_colors_skips_colours_that_have_swatches() {
|
||||
assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global);
|
||||
}
|
||||
|
||||
/// Give `id` the fill `p` exactly as written (a command would round or convert some colors).
|
||||
fn set_fill(s: &mut Session, id: NodeId, p: Paint) {
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.set_fill(p);
|
||||
}
|
||||
|
||||
/// Rectangles filled with repeated colors, near-equal colors that get the same name, a color and
|
||||
/// its twin with a -0.0 component, a color whose name a swatch already has, and colors two
|
||||
/// swatches already have (one of them through -0.0). Returns the rectangles in paint order.
|
||||
fn used_colors_doc(s: &mut Session) -> Vec<NodeId> {
|
||||
run(s, "swatch.new", json!({"name": "Gray K=33", "color": "#00ff00"}));
|
||||
run(s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"}));
|
||||
let red = doc(s).swatch("Red").unwrap().paint.color().unwrap();
|
||||
let colors = [
|
||||
Color::rgb(0.2, 0.4, 0.6),
|
||||
Color::rgb(0.2, 0.4, 0.6),
|
||||
Color::rgb(0.2, 0.4, 0.6001),
|
||||
Color::rgb(0.0, 0.4, 0.6),
|
||||
Color::rgb(-0.0, 0.4, 0.6),
|
||||
Color::gray(0.33),
|
||||
Color::gray(0.3301),
|
||||
red,
|
||||
Color::rgb(-0.0, 128.0 / 255.0, 1.0),
|
||||
Color::rgb(0.2, 0.4, 0.6001),
|
||||
];
|
||||
colors
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let id = rect_filled(s, json!({"color": "#000000"}));
|
||||
set_fill(s, id, Paint::solid(c));
|
||||
id
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const USED_NAMES: [&str; 5] = ["R=51 G=102 B=153", "R=51 G=102 B=153 2", "R=0 G=102 B=153", "Gray K=33 2", "Gray K=33 3"];
|
||||
|
||||
#[test]
|
||||
fn add_used_colors_adds_each_new_color_once_with_free_names() {
|
||||
let mut s = session();
|
||||
used_colors_doc(&mut s);
|
||||
let before = doc(&s).clone();
|
||||
let undo = undo_len(&s);
|
||||
let r = run(&mut s, "swatch.addUsedColors", json!({}));
|
||||
assert_eq!(r, json!({"added": USED_NAMES, "linked": 0}));
|
||||
assert_eq!(names(&s, None)[names(&s, None).len() - 5..], USED_NAMES);
|
||||
let color = |n: &str| doc(&s).swatch(n).unwrap().paint.color().unwrap();
|
||||
assert_eq!(color("R=51 G=102 B=153 2"), Color::rgb(0.2, 0.4, 0.6001));
|
||||
assert_eq!(color("Gray K=33 3"), Color::gray(0.3301));
|
||||
assert_eq!(undo_len(&s), undo + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*doc(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_used_colors_global_links_every_paint_of_a_new_color() {
|
||||
let mut s = session();
|
||||
let ids = used_colors_doc(&mut s);
|
||||
let r = run(&mut s, "swatch.addUsedColors", json!({"global": true}));
|
||||
assert_eq!(r, json!({"added": USED_NAMES, "linked": 8}));
|
||||
let link = |s: &Session, id: NodeId| match fill(s, id) {
|
||||
Paint::Solid { swatch, .. } => swatch,
|
||||
p => panic!("{p:?}"),
|
||||
};
|
||||
// The index in USED_NAMES of the swatch each rectangle links to.
|
||||
let want = [Some(0), Some(0), Some(1), Some(2), Some(2), Some(3), Some(4), None, None, Some(1)];
|
||||
let got: Vec<Option<String>> = ids.iter().map(|id| link(&s, *id)).collect();
|
||||
assert_eq!(got, want.map(|i: Option<usize>| i.map(|i| USED_NAMES[i].to_string())));
|
||||
assert!(USED_NAMES.iter().all(|n| doc(&s).swatch(n).unwrap().global));
|
||||
}
|
||||
|
||||
/// A paint linked to Registration keeps its link when a new global swatch has the same color.
|
||||
#[test]
|
||||
fn add_used_colors_global_leaves_registration_links_alone() {
|
||||
let mut s = session();
|
||||
let reg_paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 };
|
||||
let registration = rect_filled(&mut s, json!({"color": "#000000"}));
|
||||
set_fill(&mut s, registration, reg_paint.clone());
|
||||
let plain = rect_filled(&mut s, json!({"color": "#000000"}));
|
||||
set_fill(&mut s, plain, Paint::solid(Color::cmyk(1.0, 1.0, 1.0, 1.0)));
|
||||
let r = run(&mut s, "swatch.addUsedColors", json!({"global": true}));
|
||||
assert_eq!(r["linked"], json!(1), "{r}");
|
||||
let name = r["added"][0].as_str().unwrap().to_string();
|
||||
assert_eq!(fill(&s, registration), reg_paint);
|
||||
assert_eq!(fill(&s, plain), Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(name), tint: 1.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_matches_links_tints_colors_gradients_and_patterns_exactly() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.edit", json!({"name": "Teal", "global": true}));
|
||||
run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 40}));
|
||||
run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 70}));
|
||||
run(&mut s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"}));
|
||||
let teal = doc(&s).swatch("Teal").unwrap().paint.color().unwrap();
|
||||
let tinted = rect_filled(&mut s, json!({"color": "#000000"}));
|
||||
set_fill(&mut s, tinted, Paint::Solid { color: teal.tinted(0.4), swatch: Some("Teal".into()), tint: 0.4 });
|
||||
let zero = rect_filled(&mut s, json!({"color": "#000000"}));
|
||||
set_fill(&mut s, zero, Paint::solid(Color::rgb(-0.0, 128.0 / 255.0, 1.0)));
|
||||
// Sunset's gradient without a link to it, beside gradients with Sunset's stop colors and a
|
||||
// different midpoint or opacity; Ocean's with one stop moved, and Ocean's with another kind,
|
||||
// method or dither.
|
||||
let gradient = |s: &Session, n: &str| match &doc(s).swatch(n).unwrap().paint {
|
||||
Paint::Gradient(g) => g.gradient.clone(),
|
||||
p => panic!("{p:?}"),
|
||||
};
|
||||
let sunset = gradient(&s, "Sunset");
|
||||
let (mut midpoint, mut opacity) = (sunset.clone(), sunset.clone());
|
||||
midpoint.stops[0].midpoint = 0.3;
|
||||
opacity.stops[1].opacity = 0.5;
|
||||
let ocean = gradient(&s, "Ocean");
|
||||
let (mut moved, mut radial, mut perceptual, mut dither) = (ocean.clone(), ocean.clone(), ocean.clone(), ocean);
|
||||
moved.stops[0].offset += 0.25;
|
||||
radial.kind = vectorcraft_color::GradientKind::Radial;
|
||||
perceptual.method = vectorcraft_color::GradientMethod::Perceptual;
|
||||
dither.dither = true;
|
||||
for g in [midpoint, sunset.clone(), opacity, sunset, moved, radial, perceptual, dither] {
|
||||
let id = rect_filled(&mut s, json!({"color": "#000000"}));
|
||||
set_fill(&mut s, id, Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(g))));
|
||||
}
|
||||
let r = rect_filled(&mut s, json!({"color": "#ff0000"}));
|
||||
run(&mut s, "select.set", json!({"ids": [r.0]}));
|
||||
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20}));
|
||||
run(&mut s, "object.pattern.done", json!({}));
|
||||
rect_filled(&mut s, json!({"swatch": "Dots"}));
|
||||
let names = run(&mut s, "swatch.unused", json!({}))["names"].clone();
|
||||
let names: Vec<&str> = names.as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
for used in ["Teal", "Teal 40%", "Zeroed", "Sunset", "Dots"] {
|
||||
assert!(!names.contains(&used), "{used} is used: {names:?}");
|
||||
}
|
||||
for unused in ["Teal 70%", "Ocean", "Orange", "Red"] {
|
||||
assert!(names.contains(&unused), "{unused} is unused: {names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_swatches_from_colors_are_numbered_after_names_already_taken() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "R=255 G=0 B=0 2", "color": "#0000ff"}));
|
||||
run(&mut s, "swatch.newGroup", json!({"name": "Mine"}));
|
||||
let before = doc(&s).clone();
|
||||
let undo = undo_len(&s);
|
||||
// Near-equal colors with the same name, and a Lab color and its twin with a -0.0 component.
|
||||
let colors = json!([
|
||||
"#ff0000",
|
||||
"#ff0000",
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
"#00ff00",
|
||||
[0.2, 0.4, 0.6],
|
||||
[0.2, 0.4, 0.6001],
|
||||
{"l": 50, "a": 0.0, "b": 0},
|
||||
{"l": 50, "a": -0.0, "b": 0}
|
||||
]);
|
||||
let r = run(&mut s, "swatch.new", json!({ "colors": colors }));
|
||||
let want = [
|
||||
"R=255 G=0 B=0",
|
||||
"R=255 G=0 B=0 3",
|
||||
"R=255 G=0 B=0 4",
|
||||
"R=0 G=255 B=0",
|
||||
"R=0 G=255 B=0 2",
|
||||
"R=51 G=102 B=153",
|
||||
"R=51 G=102 B=153 2",
|
||||
"L=50 a=0 b=0",
|
||||
"L=50 a=0 b=0 2",
|
||||
];
|
||||
assert_eq!(r, json!({"name": want[0], "names": want}));
|
||||
assert_eq!(names(&s, None)[names(&s, None).len() - want.len()..], want);
|
||||
assert!(matches!(doc(&s).swatch("L=50 a=0 b=0 2").unwrap().paint.color(), Some(Color::Lab { a, .. }) if a.is_sign_negative()));
|
||||
assert_eq!(undo_len(&s), undo + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*doc(&s), before);
|
||||
let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", "#ff0000", "#ff0000"]}));
|
||||
assert_eq!(r["names"], json!(["R=255 G=0 B=0", "R=255 G=0 B=0 3", "R=255 G=0 B=0 4"]));
|
||||
let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", "#ff0000"], "group": "Mine"}));
|
||||
assert_eq!(r["names"], json!(["R=255 G=0 B=0 5", "R=255 G=0 B=0 6"]));
|
||||
assert_eq!(names(&s, Some("Mine")), ["R=255 G=0 B=0 5", "R=255 G=0 B=0 6"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "R=255 G=0 B=0 2", "color": "#0000ff"}));
|
||||
let r = run(
|
||||
&mut s,
|
||||
"swatch.newGroup",
|
||||
json!({"name": "R=255 G=0 B=0", "swatches": ["R=255 G=0 B=0 2"], "colors": ["#ff0000", "#ff0000", "#00ff00", "#ff0000"]}),
|
||||
);
|
||||
let want = ["R=255 G=0 B=0 2", "R=255 G=0 B=0 3", "R=255 G=0 B=0 4", "R=0 G=255 B=0", "R=255 G=0 B=0 5"];
|
||||
assert_eq!(r, json!({"name": "R=255 G=0 B=0", "swatches": want, "linked": 0}));
|
||||
}
|
||||
|
||||
/// New Color Group from artwork lists each color once in paint order: a repeat and a twin with a
|
||||
/// -0.0 component are the same color, near-equal colors with the same name are numbered after the
|
||||
/// names already taken, and a paint linked to Registration brings no color. One undo step.
|
||||
#[test]
|
||||
fn new_color_group_from_artwork_lists_each_color_once_with_free_names() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "R=0 G=102 B=153", "color": "#00ff00"}));
|
||||
let colors = [
|
||||
Color::rgb(0.2, 0.4, 0.6),
|
||||
Color::rgb(0.0, 0.4, 0.6),
|
||||
Color::rgb(0.2, 0.4, 0.6001),
|
||||
Color::rgb(-0.0, 0.4, 0.6),
|
||||
Color::rgb(0.2, 0.4, 0.6),
|
||||
Color::lab(50.0, 0.0, 0.0),
|
||||
Color::lab(50.0, -0.0, 0.0),
|
||||
Color::lab(50.0, 0.0001, 0.0),
|
||||
];
|
||||
let mut ids: Vec<u64> = vec![];
|
||||
for c in colors {
|
||||
let id = rect_filled(&mut s, json!({"color": "#000000"}));
|
||||
set_fill(&mut s, id, Paint::solid(c));
|
||||
ids.push(id.0);
|
||||
}
|
||||
let registration = rect_filled(&mut s, json!({"color": "#000000"}));
|
||||
let reg_paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 };
|
||||
set_fill(&mut s, registration, reg_paint);
|
||||
ids.push(registration.0);
|
||||
run(&mut s, "select.set", json!({ "ids": ids }));
|
||||
let before = doc(&s).clone();
|
||||
let undo = undo_len(&s);
|
||||
let r = run(&mut s, "swatch.newGroup", json!({"name": "R=51 G=102 B=153 2", "fromArtwork": true}));
|
||||
let want = ["R=51 G=102 B=153", "R=0 G=102 B=153 2", "R=51 G=102 B=153 3", "L=50 a=0 b=0", "L=50 a=0 b=0 2"];
|
||||
assert_eq!(r, json!({"name": "R=51 G=102 B=153 2", "swatches": want, "linked": 8}));
|
||||
assert_eq!(names(&s, Some("R=51 G=102 B=153 2")), want);
|
||||
assert_eq!(undo_len(&s), undo + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*doc(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_color_group_numbers_extra_colors_after_the_names_it_keeps() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.newGroup", json!({"name": "Mine", "colors": ["#ff0000", "#00ff00", "#0000ff"]}));
|
||||
let before = doc(&s).clone();
|
||||
let undo = undo_len(&s);
|
||||
// Near-equal colors with the same name, and a Lab color and its twin with a -0.0 component.
|
||||
let colors = json!([
|
||||
"#0000ff",
|
||||
"#ff0000",
|
||||
"#ff0000",
|
||||
"#ff0000",
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
[0.2, 0.4, 0.6],
|
||||
[0.2, 0.4, 0.6001],
|
||||
{"l": 50, "a": 0.0, "b": 0},
|
||||
{"l": 50, "a": -0.0, "b": 0}
|
||||
]);
|
||||
let r = run(&mut s, "swatch.editGroup", json!({"group": "Mine", "colors": colors, "rename": "R=255 G=0 B=0 2"}));
|
||||
let want = [
|
||||
"R=255 G=0 B=0",
|
||||
"R=0 G=255 B=0",
|
||||
"R=0 G=0 B=255",
|
||||
"R=255 G=0 B=0 3",
|
||||
"R=255 G=0 B=0 4",
|
||||
"R=0 G=255 B=0 2",
|
||||
"R=51 G=102 B=153",
|
||||
"R=51 G=102 B=153 2",
|
||||
"L=50 a=0 b=0",
|
||||
"L=50 a=0 b=0 2",
|
||||
];
|
||||
assert_eq!(r, json!({"name": "R=255 G=0 B=0 2", "swatches": want, "relinked": 0}));
|
||||
assert_eq!(names(&s, Some("R=255 G=0 B=0 2")), want);
|
||||
assert_eq!(undo_len(&s), undo + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*doc(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_leaves_out_linked_colours_gradients_and_patterns_in_use() {
|
||||
let mut s = session();
|
||||
@@ -234,7 +503,7 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() {
|
||||
// A swatch made from a hex colour is CMYK as well; the document round-trips natively.
|
||||
let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].as_str().unwrap().to_string();
|
||||
assert_eq!(doc(&s).swatch(&name).and_then(|w| w.paint.color()), Some(managed(Color::rgb(0.0, 1.0, 0.0))));
|
||||
assert_eq!(name, "C=62 M=0 Y=100 K=0");
|
||||
assert_eq!(name, "C=61 M=0 Y=100 K=0");
|
||||
let json = serde_json::to_string(doc(&s)).unwrap();
|
||||
assert_eq!(serde_json::from_str::<vectorcraft_doc::Document>(&json).unwrap(), *doc(&s));
|
||||
}
|
||||
|
||||
@@ -243,6 +243,10 @@ fn structured_junk() {
|
||||
("addon.select.byLinkStatus", json!({"status": "embedded", "ids": {"a": 1}, "add": [1], "zoom": "yes"})),
|
||||
("addon.select.byLinkStatus", json!({"status": ["modified", "embedded"], "ids": [], "add": true, "zoom": true})),
|
||||
("addon.select.byLinkStatus", json!({"status": [[["linked"]]], "ids": null})),
|
||||
("links.relink", json!({"scope": "each", "paths": [{"ids": [2, u64::MAX, -1, "x"], "path": ""}, null, 7, {"path": [1]}]})),
|
||||
("links.relink", json!({"scope": ["all"], "path": "/no/such/file.png", "ids": {"a": 1}})),
|
||||
("links.relink", json!({"scope": "missingInFolder", "path": "", "folder": [], "paths": "x"})),
|
||||
("links.relink", json!({"scope": "all", "path": "/no/such/file.png", "ids": [2, u64::MAX, -1, 1.5]})),
|
||||
("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})),
|
||||
("object.transform", json!({"matrix": [1e308, 1e308, 1e308, 1e308, 1e308, 1e308]})),
|
||||
("object.transform", json!({"matrix": [1, 2, 3]})),
|
||||
|
||||
@@ -3799,8 +3799,9 @@ proptest! {
|
||||
}
|
||||
|
||||
/// Brush objects of every kind with any parameters, the brush manager's order with any names,
|
||||
/// and brush art sections nested or cut short: read with the layers, or as a library without
|
||||
/// them, the brushes kept within their ranges.
|
||||
/// and brush art sections nested or cut short, their art alone or in a group after a frame of
|
||||
/// any corners, painted or not, open or closed, the group ended or not: read with the layers,
|
||||
/// or as a library without them, the brushes kept within their ranges.
|
||||
#[test]
|
||||
fn ai_brushes_never_panic(
|
||||
kind in 0usize..7,
|
||||
@@ -3809,6 +3810,7 @@ proptest! {
|
||||
nest in 0usize..6,
|
||||
cut in any::<bool>(),
|
||||
layers in any::<bool>(),
|
||||
frame in prop::option::of((prop::collection::vec(prop_oneof![arb_num(), Just("0".to_string()), Just("4".to_string())], 4), prop::sample::select(vec!["n", "N", "f", "S"]), any::<bool>())),
|
||||
) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let kinds = ["Adobe Calligraphic Brush Tool", "Adobe Scatter Brush Tool", "Adobe ArtOnPath Brush Tool", "Adobe PatternOnPath Brush Tool", "Adobe dBrush Brush Tool", "Adobe Brush Manager Order", "Other"];
|
||||
@@ -3822,7 +3824,14 @@ proptest! {
|
||||
for _ in 0..nest {
|
||||
art.push_str("%AI8_BeginBrushPattern\n(A)\n");
|
||||
}
|
||||
art.push_str("0 g\n0 0 m\n4 0 L\n4 4 L\nf\n");
|
||||
match &frame {
|
||||
Some((c, op, ended)) => {
|
||||
let (x0, y0, x1, y1) = (&c[0], &c[1], &c[2], &c[3]);
|
||||
let end = if *ended { "U\n" } else { "" };
|
||||
art.push_str(&format!("u\n{x0} {y0} m\n{x1} {y0} L\n{x1} {y1} L\n{x0} {y1} L\n{x0} {y0} L\n{op}\n0 g\n0 0 m\n4 0 L\n4 4 L\nf\n{end}"));
|
||||
}
|
||||
None => art.push_str("0 g\n0 0 m\n4 0 L\n4 4 L\nf\n"),
|
||||
}
|
||||
for _ in 0..nest.saturating_sub(usize::from(cut)) {
|
||||
art.push_str("%AI8_EndBrushPattern\n");
|
||||
}
|
||||
@@ -4562,6 +4571,8 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"1e308 Ty",
|
||||
"1 TY",
|
||||
"(x) TY",
|
||||
"3 Tg",
|
||||
"1e308 Tg",
|
||||
"1 1 1e308 -1e308 4e9 Th",
|
||||
"1 1 (2) 3 4 Th",
|
||||
"36 2 Xb",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! The JavaScript parser's lexer (`vectorcraft-js`) and the script checker's (`vectorcraft-scriptcheck`)
|
||||
//! read the same scripts the same way: both crates stand alone, so each has its own lexer, and these
|
||||
//! tests run both over one set of scripts and fail on any disagreement about the lines a script has,
|
||||
//! its preprocessor lines, and where a string, comment or regular expression that is never closed
|
||||
//! starts. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which
|
||||
//! only the checker reads).
|
||||
|
||||
use vectorcraft_js::{Options, TokenKind, parse, tokenize};
|
||||
use vectorcraft_scriptcheck::{Limits, check};
|
||||
|
||||
/// The problems the checker reports from its lexer, which the parser reports as a `SyntaxError`.
|
||||
const LEXICAL: [(&str, &str); 3] = [
|
||||
("unterminatedString", "unterminated string"),
|
||||
("unterminatedComment", "unterminated comment"),
|
||||
("unterminatedRegExp", "unterminated regular expression"),
|
||||
];
|
||||
|
||||
/// Scripts both lexers read without a problem, each with a `/` or a quote in a place where reading
|
||||
/// it the other way would change the tokens.
|
||||
const VALID: &[&str] = &[
|
||||
"var a = b / c / d;\nvar r = /ab+c/gi;\n",
|
||||
"if (x) /re/.test(y);\nwhile (z) /q/g.exec(w);\n",
|
||||
"function f() { return /x/; }\nvar g = function () {} / 2;\n",
|
||||
"{}\n/re/.test(s);\n",
|
||||
"x = {a: 1} / 2;\n",
|
||||
"a = b\n/c/g\n",
|
||||
"s = 'it\\'s \"q\" // not a comment';\nt = \"/* nor this */\";\n",
|
||||
"s = 'line\\\ncontinued';\nu = 1;\n",
|
||||
"x = a++ / 2;\ny = (b) / 2;\nz = c[0] / 2;\n",
|
||||
"r = /[/]/;\nq = /\\//;\n",
|
||||
"#target vectorcraft\n#include \"lib.jsxinc\"\n//@includepath \"~/x\"\nvar a = 1;\n#targetengine session\n",
|
||||
"a = 1;\r\nb = 2;\r\nc = 3;",
|
||||
"a = 1;\u{2028}b = 2;\u{2029}c = 3;\n",
|
||||
"typeof /x/ === 'object';\nvoid /y/;\n",
|
||||
"switch (x) { case /a/.test(y): break; }\n",
|
||||
"x = y ? /a/ : /b/;\n",
|
||||
"label: for (;;) { break label; }\n/* multi\nline */ z = 1 / 2;\n",
|
||||
"var o = { re: /x/, d: 4 / 2 };\n",
|
||||
"x = [/a/, 1 / 2];\n",
|
||||
"if (a) {\n} else /x/.test(b);\n",
|
||||
"a = 'q' + \"r\";\n// a comment with a 'quote\n",
|
||||
"x = a\n++b\n",
|
||||
"for (var i = 0; i < n / 2; i++) { s += /\\d/.exec(t[i]) ? 1 : 0; }\n",
|
||||
"",
|
||||
"\n\n",
|
||||
];
|
||||
|
||||
/// Scripts with a string, comment or regular expression that is never closed: the problem the
|
||||
/// checker gives and the line it starts on.
|
||||
const INVALID: &[(&str, &str, usize)] = &[
|
||||
("var a = 1;\nvar s = 'abc;\n", "unterminatedString", 2),
|
||||
("a = 1;\n/* never closed\nb = 2;\n", "unterminatedComment", 2),
|
||||
("x = 1;\ny = /abc\n", "unterminatedRegExp", 2),
|
||||
("if (x)\n /abc\n", "unterminatedRegExp", 2),
|
||||
("a = 1;\r\nb = \"x\r\n", "unterminatedString", 2),
|
||||
("q = 2 / 3;\nr = /[/]\n", "unterminatedRegExp", 2),
|
||||
];
|
||||
|
||||
fn js_options() -> Options {
|
||||
Options { preprocessor_lines: true, ..Options::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_lexers_count_the_same_lines_and_read_the_same_directives() {
|
||||
for src in VALID {
|
||||
let program = parse(src, &js_options()).unwrap_or_else(|e| panic!("{src:?}: {e}"));
|
||||
let report = check("script.jsx", src.as_bytes(), None, &Limits::default());
|
||||
assert_eq!(report.lines, program.line_count() as usize, "lines of {src:?}");
|
||||
let lexical: Vec<_> = report.problems.iter().filter(|p| LEXICAL.iter().any(|(code, _)| *code == p.code)).collect();
|
||||
assert!(lexical.is_empty(), "{src:?}: the checker reports {lexical:?}");
|
||||
let theirs: Vec<(String, usize)> = report.directives.iter().map(|d| (d.name.to_string(), d.line)).collect();
|
||||
let ours: Vec<(String, usize)> = program.directives.iter().map(|d| (d.name.to_lowercase(), d.line as usize)).collect();
|
||||
assert_eq!(ours, theirs, "directives of {src:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_lexers_find_the_same_unclosed_token_on_the_same_line() {
|
||||
for (src, code, line) in INVALID {
|
||||
let report = check("script.jsx", src.as_bytes(), None, &Limits::default());
|
||||
let theirs = report.problems.iter().find(|p| LEXICAL.iter().any(|(c, _)| *c == p.code));
|
||||
let theirs = theirs.unwrap_or_else(|| panic!("{src:?}: the checker reports no lexical problem"));
|
||||
assert_eq!((theirs.code, theirs.line), (*code, Some(*line)), "{src:?}");
|
||||
let ours = parse(src, &js_options()).expect_err(src);
|
||||
let message = LEXICAL.iter().find(|(c, _)| c == code).map(|(_, m)| *m).unwrap();
|
||||
assert!(ours.message.contains(message), "{src:?}: {}", ours.message);
|
||||
assert_eq!(ours.line as usize, *line, "{src:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_parser_reads_a_regexp_wherever_the_checker_does() {
|
||||
// Each `/` that divides has no second `/` after it on its line, so a lexer that read one as a
|
||||
// regular expression would report it as never closed. The checker reports nothing, and the
|
||||
// parser reads regular expressions on lines 2 and 3 only.
|
||||
let src = "a = b / c;\nr = /x/;\nif (a) /y/.test(b);\nz = (1) / 2;\n";
|
||||
let tokens = tokenize(src, &js_options()).unwrap();
|
||||
let lines: Vec<u32> = tokens.iter().filter(|t| matches!(t.kind, TokenKind::RegExp { .. })).map(|t| t.line).collect();
|
||||
assert_eq!(lines, [2, 3]);
|
||||
let report = check("script.jsx", src.as_bytes(), None, &Limits::default());
|
||||
assert!(report.problems.iter().all(|p| !LEXICAL.iter().any(|(c, _)| *c == p.code)), "{:?}", report.problems);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod dimensions;
|
||||
mod eyedropper_sources;
|
||||
mod find_replace_paras;
|
||||
mod inline_text;
|
||||
mod js_lexer_vs_scriptcheck;
|
||||
mod known_bugs;
|
||||
mod lossy_overwrite;
|
||||
mod model_based;
|
||||
|
||||
@@ -268,4 +268,98 @@ proptest! {
|
||||
}
|
||||
renders(&s, "after mutated modules")?;
|
||||
}
|
||||
|
||||
/// Manifests with hostile `permissions`, `api` and names (unknown, reserved and repeated
|
||||
/// permissions, junk of every type, control and bidirectional override characters) parse to
|
||||
/// an error or to a manifest whose permissions fit its kind, and installing one with a junk
|
||||
/// `grant` ends as an error or an install, never a panic. A prompt never shows a hidden
|
||||
/// character from the name.
|
||||
#[test]
|
||||
fn hostile_manifest_permissions_never_crash(
|
||||
kind in prop::sample::select(vec!["filter", "effect", "exporter"]),
|
||||
names in prop::collection::vec(
|
||||
prop_oneof![
|
||||
prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "progress", "ui.panel", ""]),
|
||||
Just("doc.read\u{202E}"),
|
||||
Just("\u{0007}"),
|
||||
],
|
||||
0..6,
|
||||
),
|
||||
permissions in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]),
|
||||
api in prop_oneof![2 => Just(json!(1)), 1 => Just(json!(2)), 1 => arb_junk()],
|
||||
name in prop_oneof![3 => "[a-z\u{202E}\u{2066}\u{200F} ]{1,64}", 1 => "[a-z\u{202E}\u{2066}\u{0007}\u{200F} ]{1,80}"],
|
||||
grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "progress"]))],
|
||||
) {
|
||||
let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api});
|
||||
if kind == "exporter" {
|
||||
m["extensions"] = json!(["fz"]);
|
||||
}
|
||||
m["permissions"] = match permissions {
|
||||
Some(Value::Null) | None => json!(names),
|
||||
Some(junk) => junk,
|
||||
};
|
||||
let text = m.to_string();
|
||||
let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes()));
|
||||
let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?;
|
||||
if let Ok(man) = &parsed {
|
||||
prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind)), "{}", text);
|
||||
let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
|
||||
prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown);
|
||||
prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
|
||||
}
|
||||
let mut s = rich_session();
|
||||
let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&module);
|
||||
let r = catch_quiet(|| {
|
||||
let _ = s.execute("plugin.inspect", &json!({"dataBase64": data}));
|
||||
s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant})).is_ok()
|
||||
});
|
||||
let installed = r.map_err(|msg| TestCaseError::fail(format!("panicked installing {text}: {msg}")))?;
|
||||
prop_assert!(!installed || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text);
|
||||
vectorcraft_plugins::registry::remove("fuzz.permissions");
|
||||
vectorcraft_plugins::registry::revoke("fuzz.permissions");
|
||||
}
|
||||
|
||||
/// A plug-in of API level 2 that calls the host imports with hostile arguments (any progress
|
||||
/// counts, any log level, pointers and lengths in and out of its memory, text that isn't
|
||||
/// UTF-8) runs to an error or an output, never a panic, and its report stays within the caps.
|
||||
#[test]
|
||||
fn hostile_host_import_arguments_never_crash(
|
||||
calls in prop::collection::vec(
|
||||
prop_oneof![
|
||||
(any::<i64>(), any::<i64>()).prop_map(|(d, t)| format!("(call $vc_progress (i64.const {d}) (i64.const {t}))")),
|
||||
Just("(drop (call $vc_cancelled))".to_string()),
|
||||
(prop_oneof![any::<i32>(), -1..5i32], prop_oneof![any::<i32>(), 0x1ff00..0x20100i32], prop_oneof![any::<i32>(), 0..5000i32])
|
||||
.prop_map(|(l, p, n)| format!("(drop (call $vc_log (i32.const {l}) (i32.const {p}) (i32.const {n})))")),
|
||||
],
|
||||
0..24,
|
||||
),
|
||||
bytes in prop::collection::vec(any::<u8>(), 0..64),
|
||||
effect in any::<bool>(),
|
||||
) {
|
||||
use vectorcraft_plugins::host::{LOG_TRUNCATED, MAX_LOG_BYTES, MAX_LOG_LINE, MAX_LOG_LINES};
|
||||
let manifest = if effect {
|
||||
r#"{"id":"fuzz.host","name":"F","kind":"effect","api":2,"permissions":["doc.read","effect.render","progress"]}"#
|
||||
} else {
|
||||
r#"{"id":"fuzz.host","name":"F","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}"#
|
||||
};
|
||||
let text: String = bytes.iter().map(|b| format!("\\{b:02x}")).collect();
|
||||
let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::HOST_IMPORTS);
|
||||
let wasm = wat::parse_str(tpl::with_imports(manifest, &imports, &calls.concat(), r#"{"objects":[]}"#)).unwrap();
|
||||
let r = catch_quiet(|| {
|
||||
let p = vectorcraft_plugins::Plugin::load(&wasm, vectorcraft_plugins::Limits::default())?;
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = std::rc::Rc::new(|_, _, _| {});
|
||||
Ok::<_, vectorcraft_plugins::Error>(vectorcraft_doc::cancel::scope(&token, Some(sink), || p.run_reporting(b"{\"objects\":[]}", b"{}", p.limits())))
|
||||
});
|
||||
let r = r.map_err(|msg| TestCaseError::fail(format!("panicked on {calls:?}: {msg}")))?;
|
||||
let (out, report) = r.map_err(|e| TestCaseError::fail(format!("refused at load: {e}")))?;
|
||||
prop_assert!(out.is_ok(), "{:?}", out);
|
||||
prop_assert!(report.lines.len() <= MAX_LOG_LINES + 1);
|
||||
prop_assert!(report.lines.iter().all(|l| l.text.chars().count() <= MAX_LOG_LINE));
|
||||
prop_assert!(report.lines.iter().filter(|l| l.text != LOG_TRUNCATED).map(|l| l.text.len()).sum::<usize>() <= MAX_LOG_BYTES);
|
||||
if let Some((done, total)) = report.progress {
|
||||
prop_assert!(total == 0 || done <= total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,14 +31,15 @@
|
||||
//! after a space for Art and Pattern), more fields, and last 15 numbers.
|
||||
//! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the
|
||||
//! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the
|
||||
//! rotation follows the path (14).
|
||||
//! rotation follows the path (14), and 1 Tints (1).
|
||||
//! - Art: 1 Tints (2).
|
||||
//! - Pattern: its scale as a factor (3).
|
||||
//! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle
|
||||
//! thickness, density, paint opacity and bristle length as fractions and the size in
|
||||
//! millimeters.
|
||||
//! - The art is a `%AI8_BeginBrushPattern` section: its name as a string, then the art (read by
|
||||
//! [`super::read`]).
|
||||
//! [`super::read`]), usually in a group after its frame, an unpainted rectangle around it. A frame
|
||||
//! at the art's bounds isn't kept ([`without_frame`]); a wider one is.
|
||||
//!
|
||||
//! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles
|
||||
//! (which slot is which tile isn't known) and the colorization of the other brushes, which is left
|
||||
@@ -327,7 +328,8 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
|
||||
scatter,
|
||||
rotation,
|
||||
rotation_relative_to_path: *p14 == 1.0,
|
||||
colorization: Colorization::None,
|
||||
// The first of the 15 numbers: 1 is Tints.
|
||||
colorization: if n.first() == Some(&1.0) { Colorization::Tints } else { Colorization::None },
|
||||
modes: Some(modes),
|
||||
..Scatter::default()
|
||||
};
|
||||
@@ -471,3 +473,79 @@ pub(super) fn finish_brushes_within(
|
||||
pub(super) fn left_out_note(n: usize) -> String {
|
||||
format!("brushes VectorCraft can't read yet were left out of the Brushes panel ({n})")
|
||||
}
|
||||
|
||||
/// Is `n` a frame: an unnamed, unpainted rectangle with nothing of its own (shown, unlocked, at full
|
||||
/// opacity in Normal, with no effects, note, mask or other settings), a path of one subpath whose
|
||||
/// anchors, without handles, are its four corners in order around it: closed, with the first again
|
||||
/// at the end or not, or open with the first again at the end?
|
||||
fn unpainted_rectangle(n: &Node) -> bool {
|
||||
let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind else { return false };
|
||||
let ([sp], Some(b)) = (path.subpaths.as_slice(), n.geometric_bounds()) else { return false };
|
||||
let corner = |p: kurbo::Point| (p.x == b.x0 || p.x == b.x1) && (p.y == b.y0 || p.y == b.y1);
|
||||
let mut corners: Vec<kurbo::Point> = vec![];
|
||||
for a in &sp.anchors {
|
||||
if !corners.contains(&a.p) {
|
||||
corners.push(a.p);
|
||||
}
|
||||
}
|
||||
let last_repeats = sp.anchors.len() == 5 && sp.anchors.first().map(|a| a.p) == sp.anchors.last().map(|a| a.p);
|
||||
// Each corner to the next goes along a side, not across: one coordinate stays.
|
||||
let sides = corners.len() == 4
|
||||
&& (0..4).all(|i| match (corners.get(i), corners.get((i + 1) % 4)) {
|
||||
(Some(a), Some(b)) => (a.x == b.x) != (a.y == b.y),
|
||||
_ => false,
|
||||
});
|
||||
n.name.is_none()
|
||||
&& *n.extras() == vectorcraft_doc::NodeExtras::default()
|
||||
&& n.visible
|
||||
&& !n.locked
|
||||
&& n.opacity >= 1.0
|
||||
&& n.blend == vectorcraft_color::BlendMode::Normal
|
||||
&& !n.isolate
|
||||
&& n.knockout == vectorcraft_doc::Knockout::Neutral
|
||||
&& !n.knockout_shape
|
||||
&& n.appearance.effects.is_empty()
|
||||
&& n.appearance.items.iter().all(|i| i.paint().is_none())
|
||||
&& ((sp.closed && sp.anchors.len() == 4) || last_repeats)
|
||||
&& sides
|
||||
&& b.width() > 0.0
|
||||
&& b.height() > 0.0
|
||||
&& sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p && corner(a.p))
|
||||
}
|
||||
|
||||
/// Brush art `art` without its frame: a group whose first object is an unpainted rectangle around
|
||||
/// the rest, exactly (within 1/1000 pt), loses that rectangle, and a group without a look or state
|
||||
/// of its own (hidden, locked, isolation, knockout, opacity, blend mode, name, appearance, or a mask,
|
||||
/// note or other setting) that is left with one object is that object. A frame wider than the art
|
||||
/// is kept.
|
||||
pub(super) fn without_frame(art: Node) -> Node {
|
||||
let NodeKind::Group { children, clip: false } = &art.kind else { return art };
|
||||
let Some((first, rest)) = children.split_first() else { return art };
|
||||
let around = rest.iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b));
|
||||
let exact = match (around, first.geometric_bounds()) {
|
||||
(Some(a), Some(f)) => [a.x0 - f.x0, a.y0 - f.y0, a.x1 - f.x1, a.y1 - f.y1].iter().all(|d| d.abs() <= 1e-3),
|
||||
_ => false,
|
||||
};
|
||||
if !exact || !unpainted_rectangle(first) {
|
||||
return art;
|
||||
}
|
||||
let plain = art.opacity >= 1.0
|
||||
&& art.visible
|
||||
&& !art.locked
|
||||
&& !art.isolate
|
||||
&& art.knockout == vectorcraft_doc::Knockout::Neutral
|
||||
&& !art.knockout_shape
|
||||
&& art.blend == vectorcraft_color::BlendMode::Normal
|
||||
&& art.name.is_none()
|
||||
&& *art.extras() == vectorcraft_doc::NodeExtras::default()
|
||||
&& art.appearance.effects.is_empty()
|
||||
&& art.appearance.items.is_empty();
|
||||
if let ([only], true) = (rest, plain) {
|
||||
return Node::clone(only);
|
||||
}
|
||||
let mut out = art.clone();
|
||||
if let Some(c) = out.children_mut() {
|
||||
c.remove(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! type runs along, `TP`), the modal character and paragraph attributes
|
||||
//! (`Tr Tf Ts Tz Tt TA Tl Ta Ti`, and §15.4.7 and §22's letter and word spacing `TC TW`, hanging
|
||||
//! punctuation `Tq`, hyphenation `Th` and tab stops `Xb Xe XB`, and §15.3.4's writing direction `TV`,
|
||||
//! Roman character rotation `Tv` and Kumi blocks `Ty TY`), the paint style, and the characters (`Tx`, `Tj`,
|
||||
//! overflow `TX`; `\r` ends a paragraph). Type set in rendering modes 4 to 7 is a mask (§5.7): the
|
||||
//! Roman character rotation `Tv`, Kumi blocks `Ty TY` and binding punctuation `Tg`), the paint
|
||||
//! style, and the characters (`Tx`, `Tj`, overflow `TX`; `\r` ends a paragraph). Type set in rendering modes 4 to 7 is a mask (§5.7): the
|
||||
//! text object clips the objects of its clipping group. Final-form operators (the
|
||||
//! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the
|
||||
//! encoding pairs of the current font's `TZ`, then by the file's `TE` encoding unless that `TZ`'s
|
||||
@@ -17,7 +17,7 @@ use std::sync::Arc;
|
||||
use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded};
|
||||
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{Burasagari, CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::AnchorKind;
|
||||
|
||||
use super::super::obj::V;
|
||||
@@ -870,6 +870,19 @@ impl Reader<'_> {
|
||||
st.para.roman_hanging = on;
|
||||
}
|
||||
}
|
||||
// `bindrepeat Tg` (§15.3.4): 1 and 3 turn binding punctuation on, and 0 and 2 turn it
|
||||
// off. VectorCraft's own mapping opens it on as Burasagari's Force setting and off as no
|
||||
// Burasagari. The repeat character (2 and 3) is not kept.
|
||||
"Tg" => {
|
||||
let burasagari = match n0 {
|
||||
Some(1.0 | 3.0) => Some(Burasagari::Forced),
|
||||
Some(0.0 | 2.0) => Some(Burasagari::None),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(b) = burasagari {
|
||||
st.para.burasagari = b;
|
||||
}
|
||||
}
|
||||
// `hyphenate limitHyphLines minLeadHyphen minTailHyphen maxHyphLines Th`, kept to the
|
||||
// ranges `text.setFormat` takes.
|
||||
"Th" => {
|
||||
|
||||
@@ -648,6 +648,9 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
// An envelope's saved margins and its check against its drawn art measure type by its
|
||||
// glyphs, as the app maps it.
|
||||
vectorcraft_text::register_envelope_ink();
|
||||
let mut doc = Document::new(1.0, 1.0);
|
||||
doc.layers.clear();
|
||||
Self {
|
||||
|
||||
@@ -761,7 +761,8 @@ impl<'a> Reader<'a> {
|
||||
let node = match art.len() {
|
||||
1 => art.pop().map(Arc::unwrap_or_clone),
|
||||
_ => Some(Node::group(self.doc.alloc_id(), art)),
|
||||
};
|
||||
}
|
||||
.map(brush::without_frame);
|
||||
let Some(node) = node else { return };
|
||||
self.brush_art.insert(name.clone(), node);
|
||||
let mut colors = BrushArtColors { spots: sub.spots, stop_spots: sub.stop_spots, cmyk: sub.cmyk_colors, rgb: sub.rgb_colors };
|
||||
|
||||
@@ -360,13 +360,13 @@ impl<'a> Reader<'a> {
|
||||
"TO" => self.end_text(hidden)?,
|
||||
// `(text) justified TS`, special characters (§22.6): the reader adds its characters like `Tx`.
|
||||
"Tx" | "Tj" | "TX" | "TS" => self.text_body(w, &vals),
|
||||
"Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" | "TV" | "Tv" | "Ty" | "TY" | "Xb" | "Xe"
|
||||
| "XB" => {
|
||||
"Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" | "TV" | "Tv" | "Ty" | "TY" | "Tg" | "Xb"
|
||||
| "Xe" | "XB" => {
|
||||
self.text_attr(w, &vals);
|
||||
}
|
||||
// Attributes the reader doesn't keep (kerning pairs, line breaking, binding punctuation
|
||||
// and Japanese layout rules) and final-form positions (§15.3).
|
||||
"Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TG" | "Tg" | "Tu" | "Xu" => {}
|
||||
// Attributes the reader doesn't keep (kerning pairs, line breaking and Japanese layout
|
||||
// rules) and final-form positions (§15.3).
|
||||
"Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TG" | "Tu" | "Xu" => {}
|
||||
// `TM` (pop text matrix) and `Te` (end render), listed in §22.6, keep nothing either; the
|
||||
// reader takes `Te` with any number of operands.
|
||||
"TM" | "Te" => {}
|
||||
|
||||
@@ -3599,6 +3599,32 @@ fn legacy_type_keeps_its_spacing_hanging_punctuation_and_hyphenation() {
|
||||
assert_eq!(texts[1].para_at(0), two);
|
||||
}
|
||||
|
||||
/// `Tg` (§15.3.4) turns binding punctuation on (1 and 3) or off (0 and 2); VectorCraft opens it on
|
||||
/// as Burasagari's Force setting. Each paragraph of point type and area type keeps the value in
|
||||
/// force at its first character, and it lasts into the next text object.
|
||||
#[test]
|
||||
fn legacy_type_keeps_its_binding_punctuation() {
|
||||
use vectorcraft_doc::Burasagari;
|
||||
let burasagari = |attrs: &str| legacy_texts(&point_type(attrs, "Hi"))[0].para_at(0).burasagari;
|
||||
assert_eq!(burasagari(""), Burasagari::None);
|
||||
for on in ["1", "3"] {
|
||||
assert_eq!(burasagari(&format!("0 Tg\n{on} Tg")), Burasagari::Forced, "{on} Tg");
|
||||
}
|
||||
for off in ["0", "2"] {
|
||||
assert_eq!(burasagari(&format!("1 Tg\n{off} Tg")), Burasagari::None, "{off} Tg");
|
||||
}
|
||||
let two_paragraphs = point_type("1 Tg", "One\\r").replace("TO\n", "0 Tg\n(Two) Tx\nTO\n");
|
||||
let texts = legacy_texts(&format!("{two_paragraphs}{}{}", point_type("3 Tg", "Three"), point_type("", "Four")));
|
||||
let each: Vec<Burasagari> = vec![texts[0].para_at(0).burasagari, texts[0].para_at(1).burasagari, texts[1].para_at(0).burasagari];
|
||||
assert_eq!(each, [Burasagari::Forced, Burasagari::None, Burasagari::Forced]);
|
||||
assert_eq!(texts[2].para_at(0).burasagari, Burasagari::Forced, "the next text object keeps it");
|
||||
let frame = "10 10 m\n110 10 L\n110 110 L\n10 110 L\nN";
|
||||
let area = format!("1 To\n1 0 0 1 10 110 0 Tp\n{frame}\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n1 Tg\n(Area) Tx\nTO\n");
|
||||
let texts = legacy_texts(&area);
|
||||
assert!(matches!(texts[0].kind, vectorcraft_doc::TextKind::Area { .. }));
|
||||
assert_eq!(texts[0].para_at(0).burasagari, Burasagari::Forced, "area type");
|
||||
}
|
||||
|
||||
/// `TV` (§15.3.4) sets the writing direction of point type, area type, type on a path and linked
|
||||
/// areas, and lasts into the next text object until it changes.
|
||||
#[test]
|
||||
@@ -3739,6 +3765,10 @@ fn legacy_type_attributes_with_bad_operands_stay_as_they_were() {
|
||||
assert_eq!(p.justify, vectorcraft_doc::Justify::Right);
|
||||
assert_eq!((p.first_line_indent, p.left_indent, p.right_indent), (5.0, 6.0, 7.0));
|
||||
assert_eq!(texts[0].runs[0].style.kerning, Some(0.0), "kerning stays off");
|
||||
let texts = legacy_texts(&point_type("1 Tg\n4 Tg\n-1 Tg\n0.5 Tg\n/x Tg\n(0) Tg\nTg", "Hi"));
|
||||
assert_eq!(texts[0].para_at(0).burasagari, vectorcraft_doc::Burasagari::Forced, "binding punctuation stays on");
|
||||
let texts = legacy_texts(&point_type("0 Tg\n4 Tg\n-1 Tg\n2.5 Tg", "Hi"));
|
||||
assert_eq!(texts[0].para_at(0).burasagari, vectorcraft_doc::Burasagari::None, "binding punctuation stays off");
|
||||
let texts = legacy_texts(&point_type("1 TV\n1 Tv\n1 Ty\n2 TV\n/x TV\nTV\n-1 Tv\n(x) Tv\n0.5 Ty\n2 TY", "Hi"));
|
||||
let st = &texts[0].runs[0].style;
|
||||
assert!(texts[0].vertical && st.standard_vertical_roman && st.tate_chu_yoko);
|
||||
@@ -8341,6 +8371,172 @@ fn a_brushs_art_places_a_version_7_patterns_tiles_with_its_operands() {
|
||||
assert!(!vectorcraft_brush::library(&r.doc).iter().any(|b| b.name == "Spray"), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A Scatter brush whose 15 numbers start with 1 colors its art with Tints of the stroke, and one
|
||||
/// whose 15 numbers start with 0 keeps the art's colors. The field before the art's name doesn't
|
||||
/// count: a brush whose first field is 1 is read by its 15 numbers, and one with 8 numbers, as older
|
||||
/// files write their brushes, isn't read as a Scatter brush.
|
||||
#[test]
|
||||
fn a_scatter_brush_in_tints_comes_in_tinted() {
|
||||
use vectorcraft_brush::{BrushKind, Colorization};
|
||||
let read = |params: &str| {
|
||||
let r = read_ok(&format!("{}{}{}", brush_art("Dot art", 4.0), plugin_object(SCATTER, "Spray", params), layer("")));
|
||||
vectorcraft_brush::library(&r.doc).into_iter().find(|b| b.name == "Spray")
|
||||
};
|
||||
for (field, first, colorization) in
|
||||
[("2", "1", Colorization::Tints), ("2", "0", Colorization::None), ("1", "1", Colorization::Tints), ("1", "0", Colorization::None)]
|
||||
{
|
||||
let params = format!("{field} /Dot art/ 5 0 0 0 / {first} 1 1 0 1 1 0 0 0 0 0 0 0 0 0");
|
||||
let Some(b) = read(¶ms) else { panic!("{params}: not read") };
|
||||
let BrushKind::Scatter(s) = b.kind else { panic!("scatter") };
|
||||
assert_eq!(s.colorization, colorization, "{params}");
|
||||
}
|
||||
let older = "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0";
|
||||
assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", older), Err(brush::BrushError::Values));
|
||||
assert!(read(older).is_none(), "8 numbers aren't a Scatter brush's");
|
||||
}
|
||||
|
||||
/// A brush's art section `name`: in a group, a frame (a rectangle from `frame.0` to `frame.1` on
|
||||
/// both axes, painted by `op` and followed by `after`) and a filled square from 0 to 4.
|
||||
fn framed_art(name: &str, frame: (f64, f64), op: &str, after: &str) -> String {
|
||||
format!(
|
||||
"%AI8_BeginBrushPattern\n({name})\n0 A\n0 Xw\nu\n0 XR\n{}{op}\n{after}0 g\n{}f\nU\n%AI8_EndBrushPattern\n",
|
||||
rect_path(frame.0, frame.0, frame.1, frame.1),
|
||||
rect_path(0.0, 0.0, 4.0, 4.0)
|
||||
)
|
||||
}
|
||||
|
||||
/// The frame of a brush's art, an unnamed, unpainted rectangle at the art's bounds, isn't part of
|
||||
/// the art, closed or open with its first corner again at the end. A frame wider than the art is
|
||||
/// part of it, and so are a painted rectangle and a named one, which aren't frames.
|
||||
#[test]
|
||||
fn a_brushs_frame_is_left_out_only_at_the_arts_bounds() {
|
||||
use vectorcraft_brush::BrushKind;
|
||||
let named = "%_/ArtDictionary :\n%_/XMLUID : (Edge) ; (AI10_ArtUID) ,\n%_;\n%_\n";
|
||||
for (why, frame, op, after, kept) in [
|
||||
("exact", (0.0, 4.0), "n", "", false),
|
||||
("open", (0.0, 4.0), "N", "", false),
|
||||
("wider", (-1.0, 5.0), "n", "", true),
|
||||
("filled", (0.0, 4.0), "f", "", true),
|
||||
("named", (0.0, 4.0), "n", named, true),
|
||||
] {
|
||||
let params = "2 /Dot art/ 5 0 0 0 / 0 1 1 0 1 1 0 0 0 0 0 0 0 0 0";
|
||||
let r = read_ok(&format!("{}{}{}", framed_art("Dot art", frame, op, after), plugin_object(SCATTER, "Spray", params), layer("")));
|
||||
let lib = vectorcraft_brush::library(&r.doc);
|
||||
let BrushKind::Scatter(s) = brush_named(&lib, "Spray") else { panic!("scatter") };
|
||||
if kept {
|
||||
assert!(matches!(s.art.kind, NodeKind::Group { .. }), "{why}: {:#?}", s.art);
|
||||
assert_eq!(s.art.children().map(|c| c.len()), Some(2), "{why}");
|
||||
} else {
|
||||
assert_eq!(s.art.geometric_bounds().map(|b| b.width()), Some(4.0), "{why}: {:#?}", s.art);
|
||||
assert!(matches!(s.art.kind, NodeKind::Path { .. }), "{why}: the square alone: {:#?}", s.art);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A brush's art group that is hidden, locked, isolated or has a note keeps that state when its
|
||||
/// frame is left out: the group stays, without the frame, rather than becoming its one other object.
|
||||
#[test]
|
||||
fn a_brushs_frame_leaves_the_groups_own_state() {
|
||||
use vectorcraft_doc::{Appearance, FillLayer, NodeId};
|
||||
let square = Rect::new(0.0, 0.0, 4.0, 4.0);
|
||||
let paint = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
let ap = Appearance { items: vec![AppearanceItem::Fill(paint)], effects: vec![], contents_index: None };
|
||||
let dot = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(square), ap);
|
||||
let frame = Node::path(NodeId(1), vectorcraft_geom::shapes::rectangle(square), Default::default());
|
||||
let group = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(dot)]);
|
||||
assert!(matches!(super::brush::without_frame(group.clone()).kind, NodeKind::Path { .. }), "a plain group is its one object");
|
||||
// A closed path through the four corners across the square, not along its sides, isn't a frame.
|
||||
let corners = [Point::new(0.0, 0.0), Point::new(4.0, 4.0), Point::new(4.0, 0.0), Point::new(0.0, 4.0)];
|
||||
let crossing = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&corners, true));
|
||||
let bowtie = Node::path(NodeId(4), crossing, Default::default());
|
||||
let dot = group.children().and_then(|c| c.last().cloned()).unwrap();
|
||||
let crossed = Node::group(NodeId(5), vec![Arc::new(bowtie), dot]);
|
||||
assert_eq!(super::brush::without_frame(crossed).children().map(|c| c.len()), Some(2), "a path across the corners stays");
|
||||
// An open path along three sides isn't a frame; one that comes back to its first corner is.
|
||||
let sides = [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0), Point::new(0.0, 4.0)];
|
||||
let around = [sides.as_slice(), &sides[..1]].concat();
|
||||
for (why, anchors, frame) in [("three sides", sides.as_slice(), false), ("back to the first corner", around.as_slice(), true)] {
|
||||
let open = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(anchors, false));
|
||||
let dot = group.children().and_then(|c| c.last().cloned()).unwrap();
|
||||
let g = Node::group(NodeId(6), vec![Arc::new(Node::path(NodeId(7), open, Default::default())), dot]);
|
||||
let left = if frame { None } else { Some(2) };
|
||||
assert_eq!(super::brush::without_frame(g).children().map(|c| c.len()), left, "{why}");
|
||||
}
|
||||
// A rectangle with settings of its own (a note, Show Center, hidden) isn't a frame.
|
||||
for why in ["note", "show center", "hidden"] {
|
||||
let mut g = group.clone();
|
||||
if let Some(Some(first)) = g.children_mut().map(|c| c.first_mut()) {
|
||||
let first = Arc::make_mut(first);
|
||||
match why {
|
||||
"note" => first.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { note: "edge".into(), ..Default::default() })),
|
||||
"show center" => {
|
||||
first.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { show_center: Some(true), ..Default::default() }))
|
||||
}
|
||||
_ => first.visible = false,
|
||||
}
|
||||
}
|
||||
assert_eq!(super::brush::without_frame(g).children().map(|c| c.len()), Some(2), "{why}: the rectangle stays");
|
||||
}
|
||||
for why in ["hidden", "locked", "isolated", "note"] {
|
||||
let mut g = group.clone();
|
||||
(g.visible, g.locked, g.isolate) = (why != "hidden", why == "locked", why == "isolated");
|
||||
if why == "note" {
|
||||
g.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { note: "edge".into(), ..Default::default() }));
|
||||
}
|
||||
let out = super::brush::without_frame(g);
|
||||
assert!(matches!(out.kind, NodeKind::Group { .. }), "{why}: {out:#?}");
|
||||
assert_eq!(out.children().map(|c| c.len()), Some(1), "{why}: the frame left out");
|
||||
assert_eq!((out.visible, out.locked, out.isolate), (why != "hidden", why == "locked", why == "isolated"), "{why}");
|
||||
assert_eq!(out.extras().attrs.as_ref().map(|a| a.note.as_str()), (why == "note").then_some("edge"), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaving out a frame that is exactly the art's bounds doesn't move what a Scatter or Art brush
|
||||
/// draws: the pieces along a path are where they are with the frame kept.
|
||||
#[test]
|
||||
fn a_brushs_frame_at_the_arts_bounds_draws_the_same_without_it() {
|
||||
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Scatter};
|
||||
let scatter = ("Spray", "Dot art", SCATTER, "2 /Dot art/ 5 0 0 0 / 0 1 1 0 1 1 0 0 0 0 0 0 0 0 0");
|
||||
for (name, art_name, tool, params) in [scatter, ("Ribbon", "Ribbon art", ART_BRUSH, RIBBON)] {
|
||||
let r = read_ok(&format!("{}{}{}", framed_art(art_name, (0.0, 4.0), "n", ""), plugin_object(tool, name, params), layer("")));
|
||||
let lib = vectorcraft_brush::library(&r.doc);
|
||||
let read = lib.iter().find(|b| b.name == name).cloned().unwrap();
|
||||
let art = match &read.kind {
|
||||
BrushKind::Scatter(s) => s.art.clone(),
|
||||
BrushKind::Art(a) => a.art.clone(),
|
||||
k => panic!("{name}: {k:?}"),
|
||||
};
|
||||
let bounds = art.geometric_bounds().unwrap();
|
||||
assert_eq!((bounds.width(), bounds.height()), (4.0, 4.0), "{name}");
|
||||
assert!(matches!(art.kind, NodeKind::Path { .. }), "{name}: the frame left out: {art:#?}");
|
||||
let frame = Node::path(vectorcraft_doc::NodeId(90), vectorcraft_geom::shapes::rectangle(bounds), Default::default());
|
||||
let framed_art = Node::group(vectorcraft_doc::NodeId(91), vec![Arc::new(frame), Arc::new(art)]);
|
||||
let kind = match &read.kind {
|
||||
BrushKind::Scatter(s) => BrushKind::Scatter(Scatter { art: framed_art, ..s.clone() }),
|
||||
BrushKind::Art(a) => BrushKind::Art(ArtBrush { art: framed_art, ..a.clone() }),
|
||||
k => panic!("{name}: {k:?}"),
|
||||
};
|
||||
let framed = Brush { kind, ..read.clone() };
|
||||
let mut line = kurbo::BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
line.line_to((100.0, 30.0));
|
||||
let stroke = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 1.0);
|
||||
let at = |b: &Brush| -> Vec<[i64; 4]> {
|
||||
vectorcraft_brush::stroke_pieces(&r.doc, b, &line, &stroke)
|
||||
.iter()
|
||||
.filter_map(|n| n.geometric_bounds())
|
||||
.map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| (v * 1000.0).round() as i64))
|
||||
.collect()
|
||||
};
|
||||
let (without, with) = (at(&read), at(&framed));
|
||||
assert!(!without.is_empty(), "{name}");
|
||||
if name == "Spray" {
|
||||
assert!(without.len() > 10, "{without:?}");
|
||||
}
|
||||
assert_eq!(without, with, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file without brush objects keeps the default brushes.
|
||||
#[test]
|
||||
fn a_file_without_brushes_keeps_the_default_ones() {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Reading an `.ai` file makes envelopes measure type by its glyph outlines, as the app does. One
|
||||
//! test in its own process, so nothing else has registered the measure before the file is read.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
/// The largest distance between the edges of `a` and `b`.
|
||||
fn off(a: Rect, b: Rect) -> f64 {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_an_ai_file_measures_type_in_envelopes_by_its_glyphs() {
|
||||
let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() });
|
||||
let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
|
||||
let laid = n.geometric_bounds().unwrap();
|
||||
let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap());
|
||||
assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}");
|
||||
let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap();
|
||||
assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}");
|
||||
|
||||
let file = "%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 3.0\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n";
|
||||
vectorcraft_eps::import(file.as_bytes()).unwrap();
|
||||
let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap();
|
||||
assert!(off(after, ink) < 1e-6, "after reading, type is measured by its glyphs: {after:?} vs {ink:?}");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "vectorcraft-js"
|
||||
description = "JavaScript for scripts: an ECMAScript 5.1 lexer and parser to a syntax tree with source positions"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
unicode-general-category = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,334 @@
|
||||
//! The syntax tree: expressions, statements and functions kept in arenas ([`Ast`]) and referred to
|
||||
//! by index, each with its source span. Nodes hold indices rather than boxes, so dropping a deeply
|
||||
//! nested tree does not recurse.
|
||||
|
||||
use crate::token::Span;
|
||||
|
||||
/// An expression's index in [`Ast::exprs`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ExprId(pub u32);
|
||||
|
||||
/// A statement's index in [`Ast::stmts`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct StmtId(pub u32);
|
||||
|
||||
/// A function's index in [`Ast::functions`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct FunctionId(pub u32);
|
||||
|
||||
/// An identifier as written, with its span.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Name {
|
||||
pub name: String,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UnaryOp {
|
||||
Delete,
|
||||
Void,
|
||||
Typeof,
|
||||
Plus,
|
||||
Minus,
|
||||
BitNot,
|
||||
Not,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UpdateOp {
|
||||
Increment,
|
||||
Decrement,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BinaryOp {
|
||||
Mul,
|
||||
Div,
|
||||
Mod,
|
||||
Add,
|
||||
Sub,
|
||||
Shl,
|
||||
Shr,
|
||||
UShr,
|
||||
Lt,
|
||||
Gt,
|
||||
LtEq,
|
||||
GtEq,
|
||||
Instanceof,
|
||||
In,
|
||||
Eq,
|
||||
NotEq,
|
||||
StrictEq,
|
||||
StrictNotEq,
|
||||
BitAnd,
|
||||
BitXor,
|
||||
BitOr,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LogicalOp {
|
||||
And,
|
||||
Or,
|
||||
}
|
||||
|
||||
/// `=` (`None`) or a compound assignment's operator (`+=` is `Some(BinaryOp::Add)`).
|
||||
pub type AssignOp = Option<BinaryOp>;
|
||||
|
||||
/// A property name in an object literal.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PropertyKey {
|
||||
Ident(String),
|
||||
String(Vec<u16>),
|
||||
Number(f64),
|
||||
}
|
||||
|
||||
impl PropertyKey {
|
||||
/// The key as the string a property is stored under (a number through ToString).
|
||||
pub fn to_utf16(&self) -> Vec<u16> {
|
||||
match self {
|
||||
PropertyKey::Ident(name) => name.encode_utf16().collect(),
|
||||
PropertyKey::String(s) => s.clone(),
|
||||
PropertyKey::Number(n) => crate::number::to_string(*n).encode_utf16().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PropertyValue {
|
||||
Init(ExprId),
|
||||
Get(FunctionId),
|
||||
Set(FunctionId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Property {
|
||||
pub key: PropertyKey,
|
||||
pub value: PropertyValue,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Expr {
|
||||
This,
|
||||
Ident(String),
|
||||
Null,
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
String(Vec<u16>),
|
||||
RegExp {
|
||||
pattern: Vec<u16>,
|
||||
flags: String,
|
||||
},
|
||||
/// An array literal; `None` is a hole (`[1, , 3]`).
|
||||
Array(Vec<Option<ExprId>>),
|
||||
Object(Vec<Property>),
|
||||
Function(FunctionId),
|
||||
/// `object.property`.
|
||||
Member {
|
||||
object: ExprId,
|
||||
property: Name,
|
||||
},
|
||||
/// `object[index]`.
|
||||
Index {
|
||||
object: ExprId,
|
||||
index: ExprId,
|
||||
},
|
||||
Call {
|
||||
callee: ExprId,
|
||||
args: Vec<ExprId>,
|
||||
},
|
||||
New {
|
||||
callee: ExprId,
|
||||
args: Vec<ExprId>,
|
||||
},
|
||||
Update {
|
||||
op: UpdateOp,
|
||||
prefix: bool,
|
||||
target: ExprId,
|
||||
},
|
||||
Unary {
|
||||
op: UnaryOp,
|
||||
arg: ExprId,
|
||||
},
|
||||
Binary {
|
||||
op: BinaryOp,
|
||||
left: ExprId,
|
||||
right: ExprId,
|
||||
},
|
||||
Logical {
|
||||
op: LogicalOp,
|
||||
left: ExprId,
|
||||
right: ExprId,
|
||||
},
|
||||
Conditional {
|
||||
test: ExprId,
|
||||
consequent: ExprId,
|
||||
alternate: ExprId,
|
||||
},
|
||||
Assign {
|
||||
op: AssignOp,
|
||||
target: ExprId,
|
||||
value: ExprId,
|
||||
},
|
||||
/// Comma-separated expressions.
|
||||
Sequence(Vec<ExprId>),
|
||||
/// A parenthesized expression, kept so a rule about the bare form (a label, a directive) can
|
||||
/// tell them apart.
|
||||
Paren(ExprId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VarDecl {
|
||||
pub name: Name,
|
||||
pub init: Option<ExprId>,
|
||||
}
|
||||
|
||||
/// What a `for (… in …)` statement assigns each property name to.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ForInTarget {
|
||||
/// `for (var x in o)`, with the declaration's initializer if it has one.
|
||||
Var(VarDecl),
|
||||
Expr(ExprId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ForInit {
|
||||
Var(Vec<VarDecl>),
|
||||
Expr(ExprId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SwitchCase {
|
||||
/// `None` for `default`.
|
||||
pub test: Option<ExprId>,
|
||||
pub body: Vec<StmtId>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CatchClause {
|
||||
pub param: Name,
|
||||
pub body: Vec<StmtId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Stmt {
|
||||
Var(Vec<VarDecl>),
|
||||
FunctionDecl(FunctionId),
|
||||
Expr(ExprId),
|
||||
Block(Vec<StmtId>),
|
||||
Empty,
|
||||
If { test: ExprId, consequent: StmtId, alternate: Option<StmtId> },
|
||||
DoWhile { body: StmtId, test: ExprId },
|
||||
While { test: ExprId, body: StmtId },
|
||||
For { init: Option<ForInit>, test: Option<ExprId>, update: Option<ExprId>, body: StmtId },
|
||||
ForIn { target: ForInTarget, object: ExprId, body: StmtId },
|
||||
Continue(Option<Name>),
|
||||
Break(Option<Name>),
|
||||
Return(Option<ExprId>),
|
||||
With { object: ExprId, body: StmtId },
|
||||
Switch { discriminant: ExprId, cases: Vec<SwitchCase> },
|
||||
Labeled { label: Name, body: StmtId },
|
||||
Throw(ExprId),
|
||||
Try { block: Vec<StmtId>, handler: Option<CatchClause>, finalizer: Option<Vec<StmtId>> },
|
||||
Debugger,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FunctionKind {
|
||||
Declaration,
|
||||
Expression,
|
||||
Getter,
|
||||
Setter,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Function {
|
||||
pub kind: FunctionKind,
|
||||
pub name: Option<Name>,
|
||||
pub params: Vec<Name>,
|
||||
pub body: Vec<StmtId>,
|
||||
/// The function's code is strict mode code: its own directive prologue holds `"use strict"`,
|
||||
/// or the code around it is strict.
|
||||
pub strict: bool,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
/// The arenas of a parsed script.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Ast {
|
||||
pub exprs: Vec<(Expr, Span)>,
|
||||
pub stmts: Vec<(Stmt, Span)>,
|
||||
pub functions: Vec<Function>,
|
||||
}
|
||||
|
||||
impl Ast {
|
||||
pub fn expr(&self, id: ExprId) -> Option<&Expr> {
|
||||
self.exprs.get(id.0 as usize).map(|(e, _)| e)
|
||||
}
|
||||
|
||||
pub fn expr_span(&self, id: ExprId) -> Span {
|
||||
self.exprs.get(id.0 as usize).map(|(_, s)| *s).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn stmt(&self, id: StmtId) -> Option<&Stmt> {
|
||||
self.stmts.get(id.0 as usize).map(|(s, _)| s)
|
||||
}
|
||||
|
||||
pub fn stmt_span(&self, id: StmtId) -> Span {
|
||||
self.stmts.get(id.0 as usize).map(|(_, s)| *s).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn function(&self, id: FunctionId) -> Option<&Function> {
|
||||
self.functions.get(id.0 as usize)
|
||||
}
|
||||
|
||||
/// The expression with any parentheses around it removed.
|
||||
pub fn unparenthesized(&self, mut id: ExprId) -> ExprId {
|
||||
while let Some(Expr::Paren(inner)) = self.expr(id) {
|
||||
id = *inner;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
pub(crate) fn push_expr(&mut self, expr: Expr, span: Span) -> ExprId {
|
||||
self.exprs.push((expr, span));
|
||||
ExprId(u32::try_from(self.exprs.len() - 1).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
pub(crate) fn push_stmt(&mut self, stmt: Stmt, span: Span) -> StmtId {
|
||||
self.stmts.push((stmt, span));
|
||||
StmtId(u32::try_from(self.stmts.len() - 1).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
pub(crate) fn push_function(&mut self, function: Function) -> FunctionId {
|
||||
self.functions.push(function);
|
||||
FunctionId(u32::try_from(self.functions.len() - 1).unwrap_or(u32::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed script.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Program {
|
||||
pub ast: Ast,
|
||||
pub body: Vec<StmtId>,
|
||||
/// The script is strict mode code (its directive prologue holds `"use strict"`, or it was
|
||||
/// parsed with [`crate::Options::strict`]).
|
||||
pub strict: bool,
|
||||
/// Its preprocessor lines, in source order.
|
||||
pub directives: Vec<crate::token::Directive>,
|
||||
pub(crate) lines: crate::LineTable,
|
||||
}
|
||||
|
||||
impl Program {
|
||||
/// The line and column (both from 1, the column in UTF-16 code units) of a source offset.
|
||||
pub fn line_column(&self, offset: u32) -> (u32, u32) {
|
||||
self.lines.line_column(offset)
|
||||
}
|
||||
|
||||
/// The number of lines in the source: its line terminators plus one for a last line without
|
||||
/// one.
|
||||
pub fn line_count(&self) -> u32 {
|
||||
self.lines.line_count()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Character classes of ECMA-262 5.1 section 7: white space, line terminators and the characters
|
||||
//! identifiers are made of.
|
||||
|
||||
use unicode_general_category::{GeneralCategory as G, get_general_category};
|
||||
|
||||
/// A line terminator: LF, CR, U+2028 or U+2029 (section 7.3).
|
||||
pub fn is_line_terminator(c: u32) -> bool {
|
||||
matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029)
|
||||
}
|
||||
|
||||
/// White space (section 7.2): tab, vertical tab, form feed, space, no-break space, the byte order
|
||||
/// mark and the other space separators (Unicode category Zs).
|
||||
pub fn is_whitespace(c: u32) -> bool {
|
||||
matches!(c, 0x09 | 0x0B | 0x0C | 0x20 | 0xA0 | 0xFEFF | 0x1680 | 0x2000..=0x200A | 0x202F | 0x205F | 0x3000)
|
||||
}
|
||||
|
||||
/// A character an identifier may start with: a letter (categories Lu, Ll, Lt, Lm, Lo and Nl),
|
||||
/// `$` or `_` (section 7.6). `\u` escapes are decoded before this check.
|
||||
pub fn is_id_start(c: u32) -> bool {
|
||||
match c {
|
||||
0x24 | 0x5F => true,
|
||||
0x41..=0x5A | 0x61..=0x7A => true,
|
||||
0..=0x7F => false,
|
||||
// U+2E2F VERTICAL TILDE is a letter (Lm) that Unicode leaves out of identifiers.
|
||||
0x2E2F => false,
|
||||
_ => category(c).is_some_and(|g| {
|
||||
matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// A character an identifier may hold after its first: those it may start with, decimal digits
|
||||
/// (Nd), combining marks (Mn, Mc), connector punctuation (Pc), and the zero width non-joiner and
|
||||
/// joiner.
|
||||
pub fn is_id_part(c: u32) -> bool {
|
||||
match c {
|
||||
0x30..=0x39 => true,
|
||||
0x200C | 0x200D => true,
|
||||
0x2E2F => false,
|
||||
_ if is_id_start(c) => true,
|
||||
0..=0x7F => false,
|
||||
_ => category(c).is_some_and(|g| matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Unicode general category of a code point; `None` for a lone surrogate.
|
||||
fn category(c: u32) -> Option<G> {
|
||||
char::from_u32(c).map(get_general_category)
|
||||
}
|
||||
|
||||
/// The value of a hexadecimal digit.
|
||||
pub fn hex_value(c: u32) -> Option<u32> {
|
||||
match c {
|
||||
0x30..=0x39 => Some(c - 0x30),
|
||||
0x41..=0x46 => Some(c - 0x41 + 10),
|
||||
0x61..=0x66 => Some(c - 0x61 + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_decimal_digit(c: u32) -> bool {
|
||||
(0x30..=0x39).contains(&c)
|
||||
}
|
||||
|
||||
pub fn is_octal_digit(c: u32) -> bool {
|
||||
(0x30..=0x37).contains(&c)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! The error a script that cannot be read gives: a `SyntaxError` with its message and where in the
|
||||
//! source it is.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// A script that does not follow the grammar, or breaks one of its early-error rules. `line` and
|
||||
/// `column` count from 1; the column counts UTF-16 code units from the start of the line, as
|
||||
/// JavaScript source positions do. `offset` is the UTF-16 offset from the start of the source.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SyntaxError {
|
||||
pub message: String,
|
||||
pub line: u32,
|
||||
pub column: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
impl fmt::Display for SyntaxError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "SyntaxError: {} (line {}, column {})", self.message, self.line, self.column)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SyntaxError {}
|
||||
|
||||
/// The message of the error nesting deeper than [`crate::Options::max_depth`] gives.
|
||||
pub const TOO_DEEP: &str = "nesting too deep";
|
||||
|
||||
/// The message of the error a source longer than [`crate::Options::max_source_units`] gives.
|
||||
pub const TOO_LONG: &str = "source too long";
|
||||
@@ -0,0 +1,637 @@
|
||||
//! The lexer (ECMA-262 5.1 section 7): source text in UTF-16 code units into tokens, with each
|
||||
//! token's line and column and whether a line terminator comes before it.
|
||||
//!
|
||||
//! A `/` starts a division punctuator or a regular expression literal depending on where the
|
||||
//! grammar is (section 7, the InputElementDiv and InputElementRegExp goals). The lexer reads a
|
||||
//! token for the goal it is given; the parser reads tokens for the division goal and asks for a
|
||||
//! token to be read again as a regular expression where an expression starts
|
||||
//! ([`Lexer::rescan_regexp`]).
|
||||
//!
|
||||
//! With [`crate::Options::preprocessor_lines`], a `#name value` line, or a `//@name value` comment
|
||||
//! that names a directive, at the start of a line (only blanks before it) gives a
|
||||
//! [`TokenKind::Directive`] token.
|
||||
//!
|
||||
//! The lexer moves forward through the source one character at a time and never recurses.
|
||||
|
||||
use crate::chars::{hex_value, is_decimal_digit, is_id_part, is_id_start, is_line_terminator, is_octal_digit, is_whitespace};
|
||||
use crate::error::SyntaxError;
|
||||
use crate::regexp;
|
||||
use crate::token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind};
|
||||
|
||||
/// Which goal symbol the next token is read for: whether a `/` there divides or starts a regular
|
||||
/// expression literal.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Goal {
|
||||
Div,
|
||||
RegExp,
|
||||
}
|
||||
|
||||
const BACKSLASH: u32 = b'\\' as u32;
|
||||
const SLASH: u32 = b'/' as u32;
|
||||
const STAR: u32 = b'*' as u32;
|
||||
const DOT: u32 = b'.' as u32;
|
||||
const CR: u32 = 0x0D;
|
||||
const LF: u32 = 0x0A;
|
||||
|
||||
/// The lexer's state. It is small and cheap to clone, which is how the parser looks one token
|
||||
/// ahead.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Lexer<'s> {
|
||||
src: &'s [u16],
|
||||
pos: usize,
|
||||
/// The current line (from 1) and the offset where it starts.
|
||||
line: u32,
|
||||
line_start: usize,
|
||||
/// Only blanks since the start of the line: a preprocessor line may start here.
|
||||
blank_line: bool,
|
||||
preprocessor_lines: bool,
|
||||
/// The deepest nesting of groups a regular expression literal may have.
|
||||
max_depth: usize,
|
||||
}
|
||||
|
||||
impl<'s> Lexer<'s> {
|
||||
/// A lexer of `src`. `preprocessor_lines` turns on the reading of preprocessor lines;
|
||||
/// `max_depth` limits how deeply a regular expression literal's groups may nest.
|
||||
pub fn new(src: &'s [u16], preprocessor_lines: bool, max_depth: usize) -> Self {
|
||||
Self { src, pos: 0, line: 1, line_start: 0, blank_line: true, preprocessor_lines, max_depth }
|
||||
}
|
||||
|
||||
/// The code unit at `pos`, as a code point value.
|
||||
fn unit(&self, pos: usize) -> Option<u32> {
|
||||
self.src.get(pos).map(|&u| u32::from(u))
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u32> {
|
||||
self.unit(self.pos)
|
||||
}
|
||||
|
||||
fn peek_at(&self, n: usize) -> Option<u32> {
|
||||
self.unit(self.pos + n)
|
||||
}
|
||||
|
||||
/// The code point at `pos` (a surrogate pair read as one) and how many units it takes.
|
||||
fn code_point(&self, pos: usize) -> Option<(u32, usize)> {
|
||||
let hi = self.unit(pos)?;
|
||||
if (0xD800..0xDC00).contains(&hi)
|
||||
&& let Some(lo) = self.unit(pos + 1).filter(|lo| (0xDC00..0xE000).contains(lo))
|
||||
{
|
||||
return Some((0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00), 2));
|
||||
}
|
||||
Some((hi, 1))
|
||||
}
|
||||
|
||||
fn column_of(&self, pos: usize) -> u32 {
|
||||
u32::try_from(pos.saturating_sub(self.line_start) + 1).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
fn offset(pos: usize) -> u32 {
|
||||
u32::try_from(pos).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// An error at `pos` on the current line.
|
||||
fn error_at(&self, pos: usize, message: impl Into<String>) -> SyntaxError {
|
||||
SyntaxError { message: message.into(), line: self.line, column: self.column_of(pos), offset: Self::offset(pos) }
|
||||
}
|
||||
|
||||
/// An error at a position recorded earlier.
|
||||
fn error_from(start: (usize, u32, u32), message: impl Into<String>) -> SyntaxError {
|
||||
SyntaxError { message: message.into(), line: start.1, column: start.2, offset: Self::offset(start.0) }
|
||||
}
|
||||
|
||||
/// Reads a line terminator at `pos` (CR LF as one), counting the line. False when there is
|
||||
/// none.
|
||||
fn line_terminator(&mut self) -> bool {
|
||||
match self.peek() {
|
||||
Some(CR) if self.peek_at(1) == Some(LF) => self.pos += 2,
|
||||
Some(c) if is_line_terminator(c) => self.pos += 1,
|
||||
_ => return false,
|
||||
}
|
||||
self.line = self.line.saturating_add(1);
|
||||
self.line_start = self.pos;
|
||||
self.blank_line = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// The next token, read for `goal`.
|
||||
pub fn next_token(&mut self, goal: Goal) -> Result<Token, SyntaxError> {
|
||||
let newline_before = match self.skip_trivia()? {
|
||||
Trivia::Directive(token) => return Ok(*token),
|
||||
Trivia::Skipped { newline } => newline,
|
||||
};
|
||||
let start = self.pos;
|
||||
let (line, column) = (self.line, self.column_of(start));
|
||||
let Some(c) = self.peek() else {
|
||||
return Ok(self.token(TokenKind::Eof, start, line, column, newline_before));
|
||||
};
|
||||
self.blank_line = false;
|
||||
let mut token = if c == BACKSLASH || is_id_start(c) || self.code_point(start).is_some_and(|(cp, n)| n == 2 && is_id_start(cp)) {
|
||||
self.identifier(start, line, column)?
|
||||
} else if is_decimal_digit(c) || (c == DOT && self.peek_at(1).is_some_and(is_decimal_digit)) {
|
||||
self.number(start, line, column)?
|
||||
} else if c == u32::from(b'"') || c == u32::from(b'\'') {
|
||||
self.string(start, line, column)?
|
||||
} else if c == SLASH && goal == Goal::RegExp {
|
||||
self.regexp(start, line, column)?
|
||||
} else {
|
||||
let punct = self.punct().ok_or_else(|| self.error_at(start, unexpected_character(c)))?;
|
||||
self.token(TokenKind::Punct(punct), start, line, column, false)
|
||||
};
|
||||
// A string continued over a line ends on a new line, but not at its start.
|
||||
self.blank_line = false;
|
||||
token.newline_before = newline_before;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Reads the token `token` again as a regular expression literal: the parser read a `/` or
|
||||
/// `/=` where an expression starts.
|
||||
pub fn rescan_regexp(&mut self, token: &Token) -> Result<Token, SyntaxError> {
|
||||
let start = usize::try_from(token.span.start).unwrap_or(usize::MAX);
|
||||
self.pos = start;
|
||||
self.line = token.line;
|
||||
self.line_start = start.saturating_sub(usize::try_from(token.column.saturating_sub(1)).unwrap_or(0));
|
||||
self.blank_line = false;
|
||||
let mut regexp = self.regexp(start, token.line, token.column)?;
|
||||
regexp.newline_before = token.newline_before;
|
||||
Ok(regexp)
|
||||
}
|
||||
|
||||
fn token(&self, kind: TokenKind, start: usize, line: u32, column: u32, newline_before: bool) -> Token {
|
||||
Token {
|
||||
kind,
|
||||
span: Span::new(Self::offset(start), Self::offset(self.pos)),
|
||||
line,
|
||||
column,
|
||||
newline_before,
|
||||
escaped: false,
|
||||
legacy_octal: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Skips white space, line terminators and comments, and reads a preprocessor line when one
|
||||
/// starts here.
|
||||
fn skip_trivia(&mut self) -> Result<Trivia, SyntaxError> {
|
||||
let mut newline = false;
|
||||
while let Some(c) = self.peek() {
|
||||
if is_whitespace(c) {
|
||||
self.pos += 1;
|
||||
} else if self.line_terminator() {
|
||||
newline = true;
|
||||
} else if c == SLASH && self.peek_at(1) == Some(SLASH) {
|
||||
if self.blank_line && self.preprocessor_lines && self.names_directive(self.pos + 2) {
|
||||
return Ok(Trivia::Directive(Box::new(self.directive("//@", newline))));
|
||||
}
|
||||
while self.peek().is_some_and(|c| !is_line_terminator(c)) {
|
||||
self.pos += 1;
|
||||
}
|
||||
} else if c == SLASH && self.peek_at(1) == Some(STAR) {
|
||||
newline |= self.block_comment()?;
|
||||
// The comment's end is on this line, so a `#` after it is not at the line's start.
|
||||
self.blank_line = false;
|
||||
} else if c == u32::from(b'#') && self.blank_line && self.preprocessor_lines && self.peek_at(1).is_some_and(is_name_char) {
|
||||
return Ok(Trivia::Directive(Box::new(self.directive("#", newline))));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Trivia::Skipped { newline })
|
||||
}
|
||||
|
||||
/// Skips a `/* */` comment; true when it holds a line terminator.
|
||||
fn block_comment(&mut self) -> Result<bool, SyntaxError> {
|
||||
let start = (self.pos, self.line, self.column_of(self.pos));
|
||||
self.pos += 2;
|
||||
let mut newline = false;
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Err(Self::error_from(start, "unterminated comment")),
|
||||
Some(STAR) if self.peek_at(1) == Some(SLASH) => {
|
||||
self.pos += 2;
|
||||
return Ok(newline);
|
||||
}
|
||||
Some(_) if self.line_terminator() => newline = true,
|
||||
Some(_) => self.pos += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `//@` at `pos - 2` is followed by a directive's name.
|
||||
fn names_directive(&self, pos: usize) -> bool {
|
||||
if self.unit(pos) != Some(u32::from(b'@')) {
|
||||
return false;
|
||||
}
|
||||
let name: String = self.src[pos + 1..].iter().take_while(|&&u| is_name_char(u32::from(u))).map(|&u| char::from(u as u8)).collect();
|
||||
DirectiveName::from_name(&name).is_some()
|
||||
}
|
||||
|
||||
/// Reads a preprocessor line written with `form` (`#` or `//@`), up to the end of the line.
|
||||
fn directive(&mut self, form: &'static str, newline_before: bool) -> Token {
|
||||
let start = self.pos;
|
||||
let (line, column) = (self.line, self.column_of(start));
|
||||
self.pos += form.encode_utf16().count();
|
||||
let name_start = self.pos;
|
||||
while self.peek().is_some_and(is_name_char) {
|
||||
self.pos += 1;
|
||||
}
|
||||
let name = String::from_utf16_lossy(&self.src[name_start..self.pos]);
|
||||
let value_start = self.pos;
|
||||
while self.peek().is_some_and(|c| !is_line_terminator(c)) {
|
||||
self.pos += 1;
|
||||
}
|
||||
let value = String::from_utf16_lossy(&self.src[value_start..self.pos]).trim().to_string();
|
||||
let directive = Directive {
|
||||
known: DirectiveName::from_name(&name),
|
||||
name,
|
||||
value,
|
||||
form,
|
||||
span: Span::new(Self::offset(start), Self::offset(self.pos)),
|
||||
line,
|
||||
};
|
||||
self.blank_line = false;
|
||||
self.token(TokenKind::Directive(directive), start, line, column, newline_before)
|
||||
}
|
||||
|
||||
/// Reads an identifier or a keyword (section 7.6), `\u` escapes included.
|
||||
fn identifier(&mut self, start: usize, line: u32, column: u32) -> Result<Token, SyntaxError> {
|
||||
let mut name = String::new();
|
||||
let mut escaped = false;
|
||||
let mut first = true;
|
||||
loop {
|
||||
let at = self.pos;
|
||||
let (c, len, is_escape) = match self.code_point(at) {
|
||||
Some((BACKSLASH, _)) => {
|
||||
let c = self.identifier_escape()?;
|
||||
(c, 0, true)
|
||||
}
|
||||
Some((c, len)) => (c, len, false),
|
||||
None => break,
|
||||
};
|
||||
let ok = if first { is_id_start(c) } else { is_id_part(c) };
|
||||
if !ok {
|
||||
if is_escape {
|
||||
return Err(self.error_at(at, "invalid character in identifier escape"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
self.pos += len;
|
||||
escaped |= is_escape;
|
||||
first = false;
|
||||
match char::from_u32(c) {
|
||||
Some(ch) => name.push(ch),
|
||||
None => return Err(self.error_at(at, "invalid character in identifier")),
|
||||
}
|
||||
}
|
||||
let kind = match Keyword::from_word(&name) {
|
||||
Some(k) if !escaped => TokenKind::Keyword(k),
|
||||
_ => TokenKind::Ident(name),
|
||||
};
|
||||
let mut token = self.token(kind, start, line, column, false);
|
||||
token.escaped = escaped;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Reads a `\uXXXX` escape in an identifier and returns its code point.
|
||||
fn identifier_escape(&mut self) -> Result<u32, SyntaxError> {
|
||||
let at = self.pos;
|
||||
if self.peek_at(1) != Some(u32::from(b'u')) {
|
||||
return Err(self.error_at(at, "invalid escape in identifier"));
|
||||
}
|
||||
self.pos += 2;
|
||||
self.unicode_escape().ok_or_else(|| self.error_at(at, "invalid Unicode escape sequence"))
|
||||
}
|
||||
|
||||
/// After `\u`: four hexadecimal digits, or a code point in braces (`\u{1F600}`, ECMA-262 2015
|
||||
/// section 11.8.4) up to U+10FFFF.
|
||||
fn unicode_escape(&mut self) -> Option<u32> {
|
||||
if self.peek() != Some(u32::from(b'{')) {
|
||||
return self.hex_digits(4);
|
||||
}
|
||||
let mut n = 1;
|
||||
let mut value: u32 = 0;
|
||||
while let Some(d) = self.peek_at(n).and_then(hex_value) {
|
||||
value = value.checked_mul(16)?.checked_add(d).filter(|&v| v <= 0x10FFFF)?;
|
||||
n += 1;
|
||||
}
|
||||
if n == 1 || self.peek_at(n) != Some(u32::from(b'}')) {
|
||||
return None;
|
||||
}
|
||||
self.pos += n + 1;
|
||||
Some(value)
|
||||
}
|
||||
|
||||
/// Reads `n` hexadecimal digits and returns their value, or `None` (leaving the position) when
|
||||
/// fewer follow.
|
||||
fn hex_digits(&mut self, n: usize) -> Option<u32> {
|
||||
let mut value = 0;
|
||||
for i in 0..n {
|
||||
value = value * 16 + hex_value(self.peek_at(i)?)?;
|
||||
}
|
||||
self.pos += n;
|
||||
Some(value)
|
||||
}
|
||||
|
||||
/// Reads a numeric literal (section 7.8.3, with the legacy octal literals of Annex B).
|
||||
fn number(&mut self, start: usize, line: u32, column: u32) -> Result<Token, SyntaxError> {
|
||||
let mut legacy_octal = false;
|
||||
let value = if self.peek() == Some(u32::from(b'0')) && self.peek_at(1).is_some_and(|c| c == u32::from(b'x') || c == u32::from(b'X')) {
|
||||
self.pos += 2;
|
||||
self.radix_digits(16).ok_or_else(|| self.error_at(start, "hexadecimal literal without digits"))?
|
||||
} else if self.peek() == Some(u32::from(b'0')) && self.peek_at(1).is_some_and(is_decimal_digit) {
|
||||
legacy_octal = true;
|
||||
self.legacy_octal_or_decimal()
|
||||
} else {
|
||||
self.decimal()
|
||||
};
|
||||
if self.peek().is_some_and(|c| c == BACKSLASH || is_id_start(c) || is_decimal_digit(c)) {
|
||||
return Err(self.error_at(self.pos, "identifier starts immediately after numeric literal"));
|
||||
}
|
||||
let mut token = self.token(TokenKind::Number(value), start, line, column, false);
|
||||
token.legacy_octal = legacy_octal;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Reads digits in `radix` (8 or 16) and returns their value, or `None` when there are none.
|
||||
fn radix_digits(&mut self, radix: u32) -> Option<f64> {
|
||||
let begin = self.pos;
|
||||
let mut exact: u128 = 0;
|
||||
let mut value = 0.0_f64;
|
||||
let mut overflow = false;
|
||||
while let Some(d) = self.peek().and_then(hex_value).filter(|&d| d < radix) {
|
||||
match exact.checked_mul(u128::from(radix)).and_then(|v| v.checked_add(u128::from(d))) {
|
||||
Some(v) if !overflow => exact = v,
|
||||
_ => {
|
||||
if !overflow {
|
||||
value = exact as f64;
|
||||
overflow = true;
|
||||
}
|
||||
value = value * f64::from(radix) + f64::from(d);
|
||||
}
|
||||
}
|
||||
self.pos += 1;
|
||||
}
|
||||
if self.pos == begin {
|
||||
return None;
|
||||
}
|
||||
Some(if overflow { value } else { exact as f64 })
|
||||
}
|
||||
|
||||
/// After `0` and a digit: an octal literal when every digit is octal, else a decimal literal
|
||||
/// whose integer part starts with `0` (`08`, `09.5`).
|
||||
fn legacy_octal_or_decimal(&mut self) -> f64 {
|
||||
let begin = self.pos;
|
||||
let mut end = begin;
|
||||
while self.unit(end).is_some_and(is_decimal_digit) {
|
||||
end += 1;
|
||||
}
|
||||
if self.src[begin..end].iter().all(|&u| is_octal_digit(u32::from(u))) {
|
||||
self.pos += 1;
|
||||
return self.radix_digits(8).unwrap_or(0.0);
|
||||
}
|
||||
self.decimal()
|
||||
}
|
||||
|
||||
/// Reads a decimal literal: digits, an optional fraction and an optional exponent.
|
||||
fn decimal(&mut self) -> f64 {
|
||||
let mut text = String::new();
|
||||
self.push_digits(&mut text);
|
||||
if text.is_empty() {
|
||||
text.push('0');
|
||||
}
|
||||
if self.peek() == Some(DOT) {
|
||||
self.pos += 1;
|
||||
text.push('.');
|
||||
let before = text.len();
|
||||
self.push_digits(&mut text);
|
||||
if text.len() == before {
|
||||
text.push('0');
|
||||
}
|
||||
}
|
||||
if self.peek().is_some_and(|c| c == u32::from(b'e') || c == u32::from(b'E')) {
|
||||
let sign = self.peek_at(1).filter(|&c| c == u32::from(b'+') || c == u32::from(b'-'));
|
||||
let digit_at = if sign.is_some() { 2 } else { 1 };
|
||||
if self.peek_at(digit_at).is_some_and(is_decimal_digit) {
|
||||
self.pos += digit_at;
|
||||
text.push('e');
|
||||
if sign == Some(u32::from(b'-')) {
|
||||
text.push('-');
|
||||
}
|
||||
self.push_digits(&mut text);
|
||||
}
|
||||
}
|
||||
text.parse().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn push_digits(&mut self, text: &mut String) {
|
||||
while let Some(c) = self.peek().filter(|&c| is_decimal_digit(c)) {
|
||||
text.push(char::from(c as u8));
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a string literal (section 7.8.4, with the octal escapes of Annex B) and returns its
|
||||
/// value.
|
||||
fn string(&mut self, start: usize, line: u32, column: u32) -> Result<Token, SyntaxError> {
|
||||
let quote = self.peek().unwrap_or_default();
|
||||
self.pos += 1;
|
||||
let mut value = Vec::new();
|
||||
let mut legacy_octal = false;
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Err(Self::error_from((start, line, column), "unterminated string literal")),
|
||||
// U+2028 and U+2029 may stand in a string, as ECMA-262 2019 allows; they still
|
||||
// start a new line for positions.
|
||||
Some(c @ (0x2028 | 0x2029)) => {
|
||||
value.push(c as u16);
|
||||
self.line_terminator();
|
||||
}
|
||||
Some(c) if is_line_terminator(c) => {
|
||||
return Err(Self::error_from((start, line, column), "unterminated string literal"));
|
||||
}
|
||||
Some(c) if c == quote => {
|
||||
self.pos += 1;
|
||||
break;
|
||||
}
|
||||
Some(BACKSLASH) => legacy_octal |= self.string_escape(&mut value)?,
|
||||
Some(c) => {
|
||||
value.push(c as u16);
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut token = self.token(TokenKind::String(value), start, line, column, false);
|
||||
token.legacy_octal = legacy_octal;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Reads an escape in a string literal into `value`; true for an octal escape (`\1`, `\08`)
|
||||
/// or `\8` and `\9`.
|
||||
fn string_escape(&mut self, value: &mut Vec<u16>) -> Result<bool, SyntaxError> {
|
||||
let at = self.pos;
|
||||
self.pos += 1;
|
||||
let Some(c) = self.peek() else {
|
||||
return Err(self.error_at(at, "unterminated string literal"));
|
||||
};
|
||||
if self.line_terminator() {
|
||||
return Ok(false);
|
||||
}
|
||||
let single = match char::from_u32(c) {
|
||||
Some('b') => Some(0x08),
|
||||
Some('t') => Some(0x09),
|
||||
Some('n') => Some(0x0A),
|
||||
Some('v') => Some(0x0B),
|
||||
Some('f') => Some(0x0C),
|
||||
Some('r') => Some(0x0D),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(unit) = single {
|
||||
value.push(unit);
|
||||
self.pos += 1;
|
||||
return Ok(false);
|
||||
}
|
||||
match char::from_u32(c) {
|
||||
Some('x') => {
|
||||
self.pos += 1;
|
||||
let unit = self.hex_digits(2).ok_or_else(|| self.error_at(at, "invalid hexadecimal escape sequence"))?;
|
||||
value.push(unit as u16);
|
||||
Ok(false)
|
||||
}
|
||||
Some('u') => {
|
||||
self.pos += 1;
|
||||
let code_point = self.unicode_escape().ok_or_else(|| self.error_at(at, "invalid Unicode escape sequence"))?;
|
||||
match char::from_u32(code_point) {
|
||||
Some(ch) if code_point > 0xFFFF => value.extend(ch.encode_utf16(&mut [0; 2]).iter()),
|
||||
_ => value.push(code_point as u16),
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
Some('0') if !self.peek_at(1).is_some_and(is_decimal_digit) => {
|
||||
self.pos += 1;
|
||||
value.push(0);
|
||||
Ok(false)
|
||||
}
|
||||
Some('0'..='7') => {
|
||||
value.push(self.octal_escape(c));
|
||||
Ok(true)
|
||||
}
|
||||
Some('8' | '9') => {
|
||||
self.pos += 1;
|
||||
value.push(c as u16);
|
||||
Ok(true)
|
||||
}
|
||||
_ => {
|
||||
self.pos += 1;
|
||||
value.push(c as u16);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an octal escape whose first digit is `first`: up to three digits when it is 0 to 3,
|
||||
/// up to two when it is 4 to 7.
|
||||
fn octal_escape(&mut self, first: u32) -> u16 {
|
||||
let max = if first <= u32::from(b'3') { 3 } else { 2 };
|
||||
let mut value = 0;
|
||||
let mut n = 0;
|
||||
while n < max
|
||||
&& let Some(d) = self.peek().filter(|&d| is_octal_digit(d))
|
||||
{
|
||||
value = value * 8 + (d - u32::from(b'0'));
|
||||
self.pos += 1;
|
||||
n += 1;
|
||||
}
|
||||
value as u16
|
||||
}
|
||||
|
||||
/// Reads a regular expression literal (section 7.8.5) and checks its flags and pattern.
|
||||
fn regexp(&mut self, start: usize, line: u32, column: u32) -> Result<Token, SyntaxError> {
|
||||
let at = (start, line, column);
|
||||
self.pos = start + 1;
|
||||
let mut in_class = false;
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Err(Self::error_from(at, "unterminated regular expression literal")),
|
||||
Some(c) if is_line_terminator(c) => return Err(Self::error_from(at, "unterminated regular expression literal")),
|
||||
Some(BACKSLASH) => {
|
||||
if self.peek_at(1).is_none_or(is_line_terminator) {
|
||||
return Err(Self::error_from(at, "unterminated regular expression literal"));
|
||||
}
|
||||
self.pos += 2;
|
||||
}
|
||||
Some(c) if c == u32::from(b'[') => {
|
||||
in_class = true;
|
||||
self.pos += 1;
|
||||
}
|
||||
Some(c) if c == u32::from(b']') => {
|
||||
in_class = false;
|
||||
self.pos += 1;
|
||||
}
|
||||
Some(SLASH) if !in_class => break,
|
||||
Some(_) => self.pos += 1,
|
||||
}
|
||||
}
|
||||
let pattern = self.src[start + 1..self.pos].to_vec();
|
||||
self.pos += 1;
|
||||
let mut flags = String::new();
|
||||
while let Some((c, len)) = self.code_point(self.pos) {
|
||||
if c == BACKSLASH {
|
||||
return Err(Self::error_from(at, "invalid regular expression flags"));
|
||||
}
|
||||
if !is_id_part(c) {
|
||||
break;
|
||||
}
|
||||
flags.push(char::from_u32(c).unwrap_or('\u{fffd}'));
|
||||
self.pos += len;
|
||||
}
|
||||
if !valid_flags(&flags) {
|
||||
return Err(Self::error_from(at, "invalid regular expression flags"));
|
||||
}
|
||||
if let Err(message) = regexp::check(&pattern, self.max_depth) {
|
||||
return Err(Self::error_from(at, format!("invalid regular expression: {message}")));
|
||||
}
|
||||
Ok(self.token(TokenKind::RegExp { pattern, flags }, start, line, column, false))
|
||||
}
|
||||
|
||||
/// Reads the longest punctuator that starts here.
|
||||
fn punct(&mut self) -> Option<Punct> {
|
||||
let rest = &self.src[self.pos..];
|
||||
for (spelling, punct) in Punct::ALL {
|
||||
let bytes = spelling.as_bytes();
|
||||
if rest.len() >= bytes.len() && rest.iter().zip(bytes).all(|(&u, &b)| u == u16::from(b)) {
|
||||
self.pos += bytes.len();
|
||||
return Some(*punct);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// What skipping the blanks and comments before a token found.
|
||||
enum Trivia {
|
||||
Skipped { newline: bool },
|
||||
Directive(Box<Token>),
|
||||
}
|
||||
|
||||
/// A character of a directive's name.
|
||||
fn is_name_char(c: u32) -> bool {
|
||||
c < 0x80 && (char::from(c as u8).is_ascii_alphanumeric() || c == u32::from(b'_'))
|
||||
}
|
||||
|
||||
/// The flags `g`, `i` and `m`, each at most once (section 15.10.4.1).
|
||||
fn valid_flags(flags: &str) -> bool {
|
||||
let mut seen = [false; 3];
|
||||
flags.chars().all(|c| {
|
||||
let i = match c {
|
||||
'g' => 0,
|
||||
'i' => 1,
|
||||
'm' => 2,
|
||||
_ => return false,
|
||||
};
|
||||
!std::mem::replace(&mut seen[i], true)
|
||||
})
|
||||
}
|
||||
|
||||
fn unexpected_character(c: u32) -> String {
|
||||
match char::from_u32(c) {
|
||||
Some(ch) if !ch.is_control() => format!("unexpected character '{ch}'"),
|
||||
_ => format!("unexpected character U+{c:04X}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! JavaScript for VectorCraft's scripts: an ECMAScript 5.1 lexer and parser
|
||||
//! (<https://262.ecma-international.org/5.1/>), with the legacy forms of Annex B that older scripts
|
||||
//! use (octal literals and escapes outside strict mode, function declarations in blocks, the
|
||||
//! lenient regular expression patterns).
|
||||
//!
|
||||
//! [`parse`] reads a script into a [`Program`]: a syntax tree whose expressions, statements and
|
||||
//! functions live in arenas and carry their source spans. A script that breaks the grammar or one
|
||||
//! of its early-error rules (section 16: strict mode's restrictions, duplicate parameters in strict
|
||||
//! code, invalid assignment targets, `break` and `continue` without a target, regular expression
|
||||
//! literals that are not valid) gives a [`SyntaxError`] with its line and column.
|
||||
//!
|
||||
//! With [`Options::preprocessor_lines`], the lexer also reads the preprocessor lines scripts begin
|
||||
//! with (`#include`, `#includepath`, `#script`, `#strict`, `#target`, `#targetengine`) into
|
||||
//! [`Directive`]s. They are kept in [`Program::directives`] and never run.
|
||||
//!
|
||||
//! The lexer reads the source as UTF-16 code units, as JavaScript does: columns and spans count
|
||||
//! code units. Nesting is counted: input nested deeper than [`Options::max_depth`] gives a
|
||||
//! `SyntaxError` ("nesting too deep") instead of exhausting the stack, binary operators, `if` …
|
||||
//! `else if` chains, labels and prefix operators are read with loops, and the syntax tree is
|
||||
//! dropped without recursion.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod ast;
|
||||
mod chars;
|
||||
mod error;
|
||||
mod lexer;
|
||||
mod number;
|
||||
mod parser;
|
||||
mod regexp;
|
||||
mod token;
|
||||
|
||||
pub use ast::{
|
||||
AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property,
|
||||
PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl,
|
||||
};
|
||||
pub use error::{SyntaxError, TOO_DEEP, TOO_LONG};
|
||||
pub use lexer::{Goal, Lexer};
|
||||
pub use number::to_string as number_to_string;
|
||||
pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind};
|
||||
|
||||
/// How a script is read.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Options {
|
||||
/// The script is strict mode code from its start, as code passed to a strict `eval` is.
|
||||
pub strict: bool,
|
||||
/// Read preprocessor lines (`#target …` and the other directives) into [`Directive`]s. Off,
|
||||
/// a `#` is an unexpected character, as ECMA-262 has it.
|
||||
pub preprocessor_lines: bool,
|
||||
/// How deeply statements, expressions, functions and regular expression groups may nest.
|
||||
pub max_depth: usize,
|
||||
/// The most UTF-16 code units a source may hold.
|
||||
pub max_source_units: usize,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self { strict: false, preprocessor_lines: false, max_depth: DEFAULT_MAX_DEPTH, max_source_units: 64 << 20 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The default [`Options::max_depth`]: deeper than scripts nest, and shallow enough for the stack
|
||||
/// of a wasm build or a secondary thread.
|
||||
pub const DEFAULT_MAX_DEPTH: usize = 200;
|
||||
|
||||
/// Parses `source` as a script.
|
||||
pub fn parse(source: &str, options: &Options) -> Result<Program, SyntaxError> {
|
||||
let units = to_utf16(source, options)?;
|
||||
parse_utf16(&units, options)
|
||||
}
|
||||
|
||||
/// Parses `source`, in UTF-16 code units, as a script.
|
||||
pub fn parse_utf16(source: &[u16], options: &Options) -> Result<Program, SyntaxError> {
|
||||
check_length(source.len(), options)?;
|
||||
parser::Parser::new(source, options, false)?.parse_program().map(|(program, _)| program)
|
||||
}
|
||||
|
||||
/// The tokens of `source`, in order, as the parser reads them: preprocessor lines included, and
|
||||
/// each `/` read as division or as the start of a regular expression literal as the grammar
|
||||
/// decides. The source must be a valid script.
|
||||
pub fn tokenize(source: &str, options: &Options) -> Result<Vec<Token>, SyntaxError> {
|
||||
let units = to_utf16(source, options)?;
|
||||
check_length(units.len(), options)?;
|
||||
parser::Parser::new(&units, options, true)?.parse_program().map(|(_, tokens)| tokens)
|
||||
}
|
||||
|
||||
fn to_utf16(source: &str, options: &Options) -> Result<Vec<u16>, SyntaxError> {
|
||||
// A source's UTF-16 length is at most its UTF-8 length; count only a source that may be too
|
||||
// long.
|
||||
if source.len() > options.max_source_units {
|
||||
check_length(source.encode_utf16().count(), options)?;
|
||||
}
|
||||
Ok(source.encode_utf16().collect())
|
||||
}
|
||||
|
||||
fn check_length(units: usize, options: &Options) -> Result<(), SyntaxError> {
|
||||
if units > options.max_source_units || u32::try_from(units).is_err() {
|
||||
return Err(SyntaxError { message: TOO_LONG.into(), line: 1, column: 1, offset: 0 });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where each line of a source starts, for turning offsets into lines and columns.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct LineTable {
|
||||
starts: Vec<u32>,
|
||||
/// The source ends with a line terminator.
|
||||
ends_with_break: bool,
|
||||
empty: bool,
|
||||
}
|
||||
|
||||
impl LineTable {
|
||||
pub(crate) fn new(src: &[u16]) -> Self {
|
||||
let mut starts = vec![0];
|
||||
let mut i = 0;
|
||||
while let Some(&u) = src.get(i) {
|
||||
i += 1;
|
||||
let terminator = match u {
|
||||
0x0D if src.get(i) == Some(&0x0A) => {
|
||||
i += 1;
|
||||
true
|
||||
}
|
||||
0x0A | 0x0D | 0x2028 | 0x2029 => true,
|
||||
_ => false,
|
||||
};
|
||||
if terminator {
|
||||
starts.push(u32::try_from(i).unwrap_or(u32::MAX));
|
||||
}
|
||||
}
|
||||
let ends_with_break = src.last().is_some_and(|&u| matches!(u, 0x0A | 0x0D | 0x2028 | 0x2029));
|
||||
Self { starts, ends_with_break, empty: src.is_empty() }
|
||||
}
|
||||
|
||||
pub(crate) fn line_column(&self, offset: u32) -> (u32, u32) {
|
||||
let line = self.starts.partition_point(|&s| s <= offset).max(1);
|
||||
let start = self.starts.get(line - 1).copied().unwrap_or(0);
|
||||
(u32::try_from(line).unwrap_or(u32::MAX), offset.saturating_sub(start) + 1)
|
||||
}
|
||||
|
||||
pub(crate) fn line_count(&self) -> u32 {
|
||||
if self.empty {
|
||||
return 0;
|
||||
}
|
||||
let breaks = self.starts.len() - 1;
|
||||
u32::try_from(breaks + usize::from(!self.ends_with_break)).unwrap_or(u32::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names.
|
||||
|
||||
/// The string ToString gives for the number `x`: the shortest decimal digits that read back as
|
||||
/// `x`, laid out as section 9.8.1 says.
|
||||
pub fn to_string(x: f64) -> String {
|
||||
if x.is_nan() {
|
||||
return "NaN".into();
|
||||
}
|
||||
if x == 0.0 {
|
||||
return "0".into();
|
||||
}
|
||||
if x.is_infinite() {
|
||||
return if x > 0.0 { "Infinity".into() } else { "-Infinity".into() };
|
||||
}
|
||||
if x < 0.0 {
|
||||
return format!("-{}", to_string(-x));
|
||||
}
|
||||
// Rust's `{:e}` writes the shortest digits that round-trip: `d.ddde±x`.
|
||||
let sci = format!("{x:e}");
|
||||
let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0"));
|
||||
let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
|
||||
let k = digits.len() as i64;
|
||||
let n = exp.parse::<i64>().unwrap_or(0) + 1;
|
||||
if k <= n && n <= 21 {
|
||||
let zeros = usize::try_from(n - k).unwrap_or(0);
|
||||
format!("{digits}{}", "0".repeat(zeros))
|
||||
} else if 0 < n && n <= 21 {
|
||||
let (int, frac) = digits.split_at_checked(usize::try_from(n).unwrap_or(0)).unwrap_or((&digits, ""));
|
||||
format!("{int}.{frac}")
|
||||
} else if -6 < n && n <= 0 {
|
||||
let zeros = usize::try_from(-n).unwrap_or(0);
|
||||
format!("0.{}{digits}", "0".repeat(zeros))
|
||||
} else {
|
||||
let e = n - 1;
|
||||
let sign = if e < 0 { '-' } else { '+' };
|
||||
let (first, rest) = digits.split_at_checked(1).unwrap_or((&digits, ""));
|
||||
if rest.is_empty() { format!("{first}e{sign}{}", e.abs()) } else { format!("{first}.{rest}e{sign}{}", e.abs()) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::to_string;
|
||||
|
||||
#[test]
|
||||
fn numbers_print_as_section_9_8_1_lays_them_out() {
|
||||
let cases: &[(f64, &str)] = &[
|
||||
(0.0, "0"),
|
||||
(-0.0, "0"),
|
||||
(1.0, "1"),
|
||||
(-1.5, "-1.5"),
|
||||
(123.456, "123.456"),
|
||||
(1e21, "1e+21"),
|
||||
(1e20, "100000000000000000000"),
|
||||
(0.000001, "0.000001"),
|
||||
(1e-7, "1e-7"),
|
||||
(1.5e-7, "1.5e-7"),
|
||||
(0.1 + 0.2, "0.30000000000000004"),
|
||||
(f64::NAN, "NaN"),
|
||||
(f64::INFINITY, "Infinity"),
|
||||
(f64::NEG_INFINITY, "-Infinity"),
|
||||
(5e-324, "5e-324"),
|
||||
(1.7976931348623157e308, "1.7976931348623157e+308"),
|
||||
];
|
||||
for (x, want) in cases {
|
||||
assert_eq!(to_string(*x), *want, "{x:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
//! The early check of a regular expression literal's pattern (ECMA-262 5.1 section 15.10.1, with
|
||||
//! the lenient forms of Annex B that scripts rely on: a `]`, `{` or `}` that starts no class or
|
||||
//! quantifier is a literal character, a lookahead may take a quantifier, and a class range with a
|
||||
//! class escape at one end is a set of literal characters).
|
||||
//!
|
||||
//! The check reads the pattern once, front to back, counting open groups instead of recursing.
|
||||
|
||||
/// Checks `pattern`, the text between a literal's slashes. Groups may nest `max_depth` deep.
|
||||
pub fn check(pattern: &[u16], max_depth: usize) -> Result<(), String> {
|
||||
let mut i = 0;
|
||||
let mut depth = 0usize;
|
||||
// The term before the current position may take a quantifier.
|
||||
let mut quantifiable = false;
|
||||
while let Some(&unit) = pattern.get(i) {
|
||||
let c = char::from_u32(u32::from(unit)).unwrap_or('\u{fffd}');
|
||||
match c {
|
||||
'\\' => {
|
||||
let next = pattern.get(i + 1).ok_or("\\ at end of pattern")?;
|
||||
quantifiable = !matches!(char::from_u32(u32::from(*next)), Some('b' | 'B'));
|
||||
i += 2;
|
||||
}
|
||||
'(' => {
|
||||
if pattern.get(i + 1) == Some(&u16::from(b'?')) {
|
||||
match pattern.get(i + 2).and_then(|&u| char::from_u32(u32::from(u))) {
|
||||
Some(':' | '=' | '!') => i += 3,
|
||||
_ => return Err("invalid group".into()),
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
depth += 1;
|
||||
if depth > max_depth {
|
||||
return Err(crate::error::TOO_DEEP.into());
|
||||
}
|
||||
quantifiable = false;
|
||||
}
|
||||
')' => {
|
||||
depth = depth.checked_sub(1).ok_or("unmatched ')'")?;
|
||||
quantifiable = true;
|
||||
i += 1;
|
||||
}
|
||||
'[' => {
|
||||
i = class(pattern, i + 1)?;
|
||||
quantifiable = true;
|
||||
}
|
||||
'*' | '+' | '?' => {
|
||||
if !quantifiable {
|
||||
return Err("nothing to repeat".into());
|
||||
}
|
||||
i = lazy(pattern, i + 1);
|
||||
quantifiable = false;
|
||||
}
|
||||
'{' => match braces(pattern, i + 1) {
|
||||
Some((min, max, end)) => {
|
||||
if !quantifiable {
|
||||
return Err("nothing to repeat".into());
|
||||
}
|
||||
if max.is_some_and(|max| max < min) {
|
||||
return Err("numbers out of order in {} quantifier".into());
|
||||
}
|
||||
i = lazy(pattern, end);
|
||||
quantifiable = false;
|
||||
}
|
||||
None => {
|
||||
quantifiable = true;
|
||||
i += 1;
|
||||
}
|
||||
},
|
||||
'^' | '$' | '|' => {
|
||||
quantifiable = false;
|
||||
i += 1;
|
||||
}
|
||||
_ => {
|
||||
quantifiable = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if depth > 0 {
|
||||
return Err("unterminated group".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After a quantifier ending before `i`: past the `?` that makes it lazy, if there is one.
|
||||
fn lazy(pattern: &[u16], i: usize) -> usize {
|
||||
if pattern.get(i) == Some(&u16::from(b'?')) { i + 1 } else { i }
|
||||
}
|
||||
|
||||
/// A `{n}`, `{n,}` or `{n,m}` quantifier whose `{` is before `i`: its bounds and the position
|
||||
/// after its `}`. `None` when the text there is not one.
|
||||
fn braces(pattern: &[u16], i: usize) -> Option<(u64, Option<u64>, usize)> {
|
||||
let (min, mut i) = digits(pattern, i)?;
|
||||
let max = if pattern.get(i) == Some(&u16::from(b',')) {
|
||||
i += 1;
|
||||
match digits(pattern, i) {
|
||||
Some((max, end)) => {
|
||||
i = end;
|
||||
Some(max)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
Some(min)
|
||||
};
|
||||
(pattern.get(i) == Some(&u16::from(b'}'))).then_some((min, max, i + 1))
|
||||
}
|
||||
|
||||
/// Decimal digits at `i`: their value (saturated) and the position after them.
|
||||
fn digits(pattern: &[u16], mut i: usize) -> Option<(u64, usize)> {
|
||||
let start = i;
|
||||
let mut value: u64 = 0;
|
||||
while let Some(d) = pattern.get(i).and_then(|&u| char::from_u32(u32::from(u))).and_then(|c| c.to_digit(10)) {
|
||||
value = value.saturating_mul(10).saturating_add(u64::from(d));
|
||||
i += 1;
|
||||
}
|
||||
(i > start).then_some((value, i))
|
||||
}
|
||||
|
||||
/// Reads a character class whose `[` is before `i` and returns the position after its `]`.
|
||||
fn class(pattern: &[u16], mut i: usize) -> Result<usize, String> {
|
||||
if pattern.get(i) == Some(&u16::from(b'^')) {
|
||||
i += 1;
|
||||
}
|
||||
// The last class atom's character (`None` for a class escape such as `\d`), when a `-` after it
|
||||
// may make a range.
|
||||
let mut prev: Option<Option<u32>> = None;
|
||||
loop {
|
||||
let unit = *pattern.get(i).ok_or("unterminated character class")?;
|
||||
if unit == u16::from(b']') {
|
||||
return Ok(i + 1);
|
||||
}
|
||||
let (atom, next) = class_atom(pattern, i)?;
|
||||
i = next;
|
||||
let dash = pattern.get(i) == Some(&u16::from(b'-')) && pattern.get(i + 1).is_some_and(|&u| u != u16::from(b']'));
|
||||
match prev.take() {
|
||||
Some(low) => {
|
||||
if let (Some(low), Some(high)) = (low, atom)
|
||||
&& low > high
|
||||
{
|
||||
return Err("range out of order in character class".into());
|
||||
}
|
||||
}
|
||||
None if dash => {
|
||||
prev = Some(atom);
|
||||
i += 1;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A class atom at `i`: its character (`None` for a class escape) and the position after it.
|
||||
fn class_atom(pattern: &[u16], i: usize) -> Result<(Option<u32>, usize), String> {
|
||||
let unit = u32::from(*pattern.get(i).ok_or("unterminated character class")?);
|
||||
if unit != u32::from(b'\\') {
|
||||
return Ok((Some(unit), i + 1));
|
||||
}
|
||||
let next = u32::from(*pattern.get(i + 1).ok_or("\\ at end of pattern")?);
|
||||
let c = char::from_u32(next).unwrap_or('\u{fffd}');
|
||||
let hex = |n: usize| -> Option<u32> {
|
||||
(0..n).try_fold(0, |v, k| pattern.get(i + 2 + k).and_then(|&u| crate::chars::hex_value(u32::from(u))).map(|d| v * 16 + d))
|
||||
};
|
||||
Ok(match c {
|
||||
'd' | 'D' | 's' | 'S' | 'w' | 'W' => (None, i + 2),
|
||||
'b' => (Some(0x08), i + 2),
|
||||
'f' => (Some(0x0C), i + 2),
|
||||
'n' => (Some(0x0A), i + 2),
|
||||
'r' => (Some(0x0D), i + 2),
|
||||
't' => (Some(0x09), i + 2),
|
||||
'v' => (Some(0x0B), i + 2),
|
||||
'x' => hex(2).map_or((Some(next), i + 2), |v| (Some(v), i + 4)),
|
||||
'u' => hex(4).map_or((Some(next), i + 2), |v| (Some(v), i + 6)),
|
||||
'c' => match pattern.get(i + 2).map(|&u| u32::from(u)) {
|
||||
Some(l) if char::from_u32(l).is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_') => (Some(l % 32), i + 3),
|
||||
_ => (Some(u32::from(b'\\')), i + 1),
|
||||
},
|
||||
'0'..='7' => {
|
||||
let mut value = 0;
|
||||
let mut j = i + 1;
|
||||
while j < i + 4
|
||||
&& let Some(d) = pattern.get(j).and_then(|&u| char::from_u32(u32::from(u))).and_then(|ch| ch.to_digit(8))
|
||||
{
|
||||
value = value * 8 + d;
|
||||
j += 1;
|
||||
}
|
||||
(Some(value), j)
|
||||
}
|
||||
_ => (Some(next), i + 2),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::{
|
||||
BinaryOp, Expr, ExprId, ForInTarget, FunctionKind, Options, Program, PropertyValue, Stmt, StmtId, SyntaxError, TOO_DEEP, TokenKind, parse,
|
||||
tokenize,
|
||||
};
|
||||
|
||||
fn ok(src: &str) -> Program {
|
||||
match parse(src, &Options::default()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => panic!("{src:?} should parse: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn err(src: &str) -> SyntaxError {
|
||||
match parse(src, &Options::default()) {
|
||||
Ok(_) => panic!("{src:?} should not parse"),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
fn strict_err(src: &str) -> SyntaxError {
|
||||
let full = format!("'use strict';\n{src}");
|
||||
match parse(&full, &Options::default()) {
|
||||
Ok(_) => panic!("{src:?} should not parse in strict mode code"),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_preprocessor() -> Options {
|
||||
Options { preprocessor_lines: true, ..Options::default() }
|
||||
}
|
||||
|
||||
/// The expression of the program's only (or first) statement.
|
||||
fn first_expr(p: &Program) -> ExprId {
|
||||
match p.ast.stmt(p.body[0]) {
|
||||
Some(Stmt::Expr(e)) => *e,
|
||||
other => panic!("not an expression statement: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn expr(p: &Program, id: ExprId) -> &Expr {
|
||||
p.ast.expr(id).unwrap()
|
||||
}
|
||||
|
||||
fn stmt(p: &Program, id: StmtId) -> &Stmt {
|
||||
p.ast.stmt(id).unwrap()
|
||||
}
|
||||
|
||||
fn utf16(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().collect()
|
||||
}
|
||||
|
||||
// ---- expressions ----
|
||||
|
||||
#[test]
|
||||
fn binary_operators_follow_precedence_and_associate_left() {
|
||||
let p = ok("a + b * c - d;");
|
||||
let Expr::Binary { op: BinaryOp::Sub, left, right } = expr(&p, first_expr(&p)) else { panic!() };
|
||||
assert!(matches!(expr(&p, *right), Expr::Ident(n) if n == "d"));
|
||||
let Expr::Binary { op: BinaryOp::Add, right: mul, .. } = expr(&p, *left) else { panic!() };
|
||||
assert!(matches!(expr(&p, *mul), Expr::Binary { op: BinaryOp::Mul, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_operators_bind_looser_than_comparisons() {
|
||||
let p = ok("a || b && c == d;");
|
||||
let Expr::Logical { right, .. } = expr(&p, first_expr(&p)) else { panic!() };
|
||||
let Expr::Logical { right: eq, .. } = expr(&p, *right) else { panic!() };
|
||||
assert!(matches!(expr(&p, *eq), Expr::Binary { op: BinaryOp::Eq, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_associates_right() {
|
||||
let p = ok("a = b += c;");
|
||||
let Expr::Assign { op: None, value, .. } = expr(&p, first_expr(&p)) else { panic!() };
|
||||
assert!(matches!(expr(&p, *value), Expr::Assign { op: Some(BinaryOp::Add), .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_takes_the_first_argument_list_after_its_callee() {
|
||||
let p = ok("new a.b(1).c(2);");
|
||||
let Expr::Call { callee, args } = expr(&p, first_expr(&p)) else { panic!() };
|
||||
assert_eq!(args.len(), 1);
|
||||
let Expr::Member { object, property } = expr(&p, *callee) else { panic!() };
|
||||
assert_eq!(property.name, "c");
|
||||
let Expr::New { callee, args } = expr(&p, *object) else { panic!() };
|
||||
assert_eq!(args.len(), 1);
|
||||
assert!(matches!(expr(&p, *callee), Expr::Member { .. }));
|
||||
|
||||
let p = ok("new new X()();");
|
||||
let Expr::New { callee, .. } = expr(&p, first_expr(&p)) else { panic!() };
|
||||
assert!(matches!(expr(&p, *callee), Expr::New { .. }));
|
||||
|
||||
let p = ok("new X;");
|
||||
assert!(matches!(expr(&p, first_expr(&p)), Expr::New { args, .. } if args.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_and_sequence_expressions() {
|
||||
let p = ok("a ? b : c ? d : e, f;");
|
||||
let Expr::Sequence(list) = expr(&p, first_expr(&p)) else { panic!() };
|
||||
assert_eq!(list.len(), 2);
|
||||
let Expr::Conditional { alternate, .. } = expr(&p, list[0]) else { panic!() };
|
||||
assert!(matches!(expr(&p, *alternate), Expr::Conditional { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_literals_keep_holes() {
|
||||
let p = ok("[1, , 3, ];");
|
||||
let Expr::Array(elements) = expr(&p, first_expr(&p)) else { panic!() };
|
||||
assert_eq!(elements.len(), 3);
|
||||
assert!(elements[1].is_none());
|
||||
let p = ok("[,];");
|
||||
assert!(matches!(expr(&p, first_expr(&p)), Expr::Array(e) if e.len() == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_literals_with_accessors_and_keyword_names() {
|
||||
let p = ok("({a: 1, 'b': 2, 3: 3, if: 4, get: 5, set: 6, get x() { return 1; }, set x(v) {}, });");
|
||||
let Expr::Paren(inner) = expr(&p, first_expr(&p)) else { panic!() };
|
||||
let Expr::Object(props) = expr(&p, *inner) else { panic!() };
|
||||
assert_eq!(props.len(), 8);
|
||||
assert!(matches!(props[6].value, PropertyValue::Get(_)));
|
||||
assert!(matches!(props[7].value, PropertyValue::Set(_)));
|
||||
let PropertyValue::Get(getter) = props[6].value else { panic!() };
|
||||
assert_eq!(p.ast.function(getter).unwrap().kind, FunctionKind::Getter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_take_the_right_number_of_parameters() {
|
||||
assert!(err("({get a(x) {}});").message.contains("getter"));
|
||||
assert!(err("({set a() {}});").message.contains("setter"));
|
||||
assert!(err("({set a(x, y) {}});").message.contains("setter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_names_may_repeat() {
|
||||
for src in ["({a: 1, a: 2});", "({a: 1, 'a': 2});", "({get a() {}, set a(v) {}});", "({a: 1, get a() {}});", "({get a() {}, get a() {}});"] {
|
||||
ok(src);
|
||||
ok(&format!("'use strict'; {src}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_get_and_set_do_not_start_accessors() {
|
||||
assert!(err("({g\\u0065t a() {}});").message.contains("expected ':'"));
|
||||
assert!(err("({\\u0073et a(v) {}});").message.contains("expected ':'"));
|
||||
ok("({g\\u0065t: 1});");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_names_may_be_reserved_words() {
|
||||
ok("a.if.var.function = b.true;");
|
||||
ok("a.\\u0076ar = 1;");
|
||||
}
|
||||
|
||||
// ---- statements ----
|
||||
|
||||
#[test]
|
||||
fn else_if_chains_nest_as_alternates() {
|
||||
let p = ok("if (a) x; else if (b) y; else z;");
|
||||
let Stmt::If { alternate: Some(alt), .. } = stmt(&p, p.body[0]) else { panic!() };
|
||||
let Stmt::If { alternate: Some(last), .. } = stmt(&p, *alt) else { panic!() };
|
||||
assert!(matches!(stmt(&p, *last), Stmt::Expr(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_statements_of_every_form() {
|
||||
ok("for (;;) break;");
|
||||
ok("for (var i = 0, n = a.length; i < n; i++) {}");
|
||||
ok("for (i = 0; i < 10; i++) continue;");
|
||||
ok("for (var k in o) {}");
|
||||
ok("for (k in o) {}");
|
||||
ok("for (a.b in o) {}");
|
||||
ok("for (var x = 1 in o) {}");
|
||||
ok("for (var i = (a in b); i; ) break;");
|
||||
let p = ok("for (var k in o) {}");
|
||||
assert!(matches!(stmt(&p, p.body[0]), Stmt::ForIn { target: ForInTarget::Var(_), .. }));
|
||||
assert!(err("for (a + b in o) {}").message.contains("for-in"));
|
||||
assert!(err("for (var a, b in o) {}").message.contains("expected ';'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_try_with_labels_and_debugger() {
|
||||
ok("switch (x) { case 1: a(); break; default: b(); case 2: }");
|
||||
ok("try { a(); } catch (e) { b(); } finally { c(); }");
|
||||
ok("try {} finally {}");
|
||||
ok("with (o) { a = 1; }");
|
||||
ok("outer: for (;;) { inner: for (;;) { continue outer; } }");
|
||||
ok("a: b: while (1) { continue a; }");
|
||||
ok("a: { break a; }");
|
||||
ok("debugger;");
|
||||
ok("do x(); while (a) y();");
|
||||
assert!(err("switch (x) { default: default: }").message.contains("default"));
|
||||
assert!(err("try {}").message.contains("catch or finally"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_and_continue_need_a_target() {
|
||||
assert!(err("break;").message.contains("break outside"));
|
||||
assert!(err("continue;").message.contains("continue outside"));
|
||||
assert!(err("switch (x) { case 1: continue; }").message.contains("continue outside"));
|
||||
assert!(err("for (;;) { break nowhere; }").message.contains("undefined label"));
|
||||
assert!(err("a: { continue a; }").message.contains("does not name a loop"));
|
||||
assert!(err("a: while (1) { function f() { break a; } }").message.contains("undefined label"));
|
||||
assert!(err("while (1) { function f() { break; } }").message.contains("break outside"));
|
||||
assert!(err("a: a: ;").message.contains("duplicate label"));
|
||||
assert!(err("a: { a: ; }").message.contains("duplicate label"));
|
||||
ok("a: ; a: ;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn return_only_inside_functions() {
|
||||
assert!(err("return 1;").message.contains("return outside"));
|
||||
ok("function f() { return; }");
|
||||
ok("(function () { return 1 })();");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_declarations_in_blocks_and_branches() {
|
||||
ok("{ function f() {} }");
|
||||
ok("if (a) function f() {} else function g() {}");
|
||||
ok("switch (a) { case 1: function f() {} }");
|
||||
ok("l: function f() {}");
|
||||
ok("{ l: m: function f() {} }");
|
||||
ok("'use strict'; { function f() {} }");
|
||||
assert!(strict_err("if (a) function f() {}").message.contains("functions can only be declared"));
|
||||
assert!(strict_err("l: function f() {}").message.contains("functions can only be declared"));
|
||||
for body in ["while (a) function f() {}", "for (;;) function f() {}", "do function f() {} while (a)", "with (a) function f() {}"] {
|
||||
assert!(err(body).message.contains("cannot stand here"), "{body}");
|
||||
}
|
||||
assert!(err("if (a) l: function f() {}").message.contains("cannot stand here"));
|
||||
assert!(err("while (a) l: function f() {}").message.contains("cannot stand here"));
|
||||
}
|
||||
|
||||
// ---- automatic semicolon insertion ----
|
||||
|
||||
#[test]
|
||||
fn semicolons_are_inserted_at_line_breaks_braces_and_the_end() {
|
||||
ok("a = 1\nb = 2");
|
||||
ok("function f() { return 1 }");
|
||||
ok("a = 1");
|
||||
let e = err("a = 1 b = 2");
|
||||
assert_eq!((e.line, e.column), (1, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_productions_end_at_a_line_break() {
|
||||
let p = ok("function f() { return\n1 }");
|
||||
let Stmt::FunctionDecl(f) = stmt(&p, p.body[0]) else { panic!() };
|
||||
let body = &p.ast.function(*f).unwrap().body;
|
||||
assert_eq!(body.len(), 2);
|
||||
assert!(matches!(stmt(&p, body[0]), Stmt::Return(None)));
|
||||
|
||||
let p = ok("a\n++b");
|
||||
assert_eq!(p.body.len(), 2);
|
||||
assert!(matches!(expr(&p, first_expr(&p)), Expr::Ident(_)));
|
||||
|
||||
ok("for (;;) { break\nfoo }");
|
||||
assert!(err("throw\nnew Error()").message.contains("line break after throw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_semicolon_is_inserted_inside_a_for_header() {
|
||||
assert!(err("for (a\nb\n) {}").message.contains("expected ';'"));
|
||||
}
|
||||
|
||||
// ---- the division and regular expression goals ----
|
||||
|
||||
#[test]
|
||||
fn slash_divides_after_a_value_and_starts_a_regexp_elsewhere() {
|
||||
let p = ok("a / b / c;");
|
||||
assert!(matches!(expr(&p, first_expr(&p)), Expr::Binary { op: BinaryOp::Div, .. }));
|
||||
let p = ok("x = /a\\/b[/]c/gi;");
|
||||
let Expr::Assign { value, .. } = expr(&p, first_expr(&p)) else { panic!() };
|
||||
let Expr::RegExp { pattern, flags } = expr(&p, *value) else { panic!() };
|
||||
assert_eq!(*pattern, utf16("a\\/b[/]c"));
|
||||
assert_eq!(flags, "gi");
|
||||
ok("if (x) /re/.test(y);");
|
||||
ok("{} /re/.test(y);");
|
||||
ok("x = y /= 2;");
|
||||
ok("x = /=a/;");
|
||||
ok("return_ = typeof /x/;");
|
||||
ok("a(/x/, /y/);");
|
||||
// After a line break a `/` still divides when it can: there is no semicolon to insert.
|
||||
let p = ok("a = b\n/c/g");
|
||||
assert_eq!(p.body.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenize_reads_each_slash_as_the_grammar_decides() {
|
||||
let tokens = tokenize("a = b / c; d = /e/g.exec(f);", &Options::default()).unwrap();
|
||||
let kinds: Vec<&TokenKind> = tokens.iter().map(|t| &t.kind).collect();
|
||||
assert!(kinds.iter().any(|k| matches!(k, TokenKind::RegExp { flags, .. } if flags == "g")));
|
||||
assert_eq!(kinds.iter().filter(|k| matches!(k, TokenKind::RegExp { .. })).count(), 1);
|
||||
assert!(matches!(kinds.last(), Some(TokenKind::Eof)));
|
||||
}
|
||||
|
||||
// ---- literals ----
|
||||
|
||||
fn number(src: &str) -> f64 {
|
||||
let p = ok(src);
|
||||
let Expr::Number(n) = expr(&p, first_expr(&p)) else { panic!("{src}") };
|
||||
*n
|
||||
}
|
||||
|
||||
fn string(src: &str) -> Vec<u16> {
|
||||
let p = ok(src);
|
||||
let Expr::String(s) = expr(&p, first_expr(&p)) else { panic!("{src}") };
|
||||
s.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_literals() {
|
||||
assert_eq!(number("0;"), 0.0);
|
||||
assert_eq!(number("12.5e-1;"), 1.25);
|
||||
assert_eq!(number(".5;"), 0.5);
|
||||
assert_eq!(number("5.;"), 5.0);
|
||||
assert_eq!(number("1E3;"), 1000.0);
|
||||
assert_eq!(number("0x1F;"), 31.0);
|
||||
assert_eq!(number("0XfF;"), 255.0);
|
||||
assert_eq!(number("0x20000000000001;"), 9007199254740992.0);
|
||||
assert_eq!(number("017;"), 15.0);
|
||||
assert_eq!(number("08;"), 8.0);
|
||||
assert_eq!(number("09.5;"), 9.5);
|
||||
assert_eq!(number("1e400;"), f64::INFINITY);
|
||||
assert!(err("3in []").message.contains("immediately after numeric literal"));
|
||||
assert!(err("0x;").message.contains("hexadecimal"));
|
||||
assert!(err("1.e").message.contains("immediately after"));
|
||||
assert!(strict_err("017;").message.contains("octal literals"));
|
||||
assert!(strict_err("08;").message.contains("octal literals"));
|
||||
assert!(strict_err("({017: 1});").message.contains("octal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_literals_and_escapes() {
|
||||
assert_eq!(string(r#""a\tb\n\\\"\'";"#), utf16("a\tb\n\\\"'"));
|
||||
assert_eq!(string(r#"'\x41é€';"#), utf16("A\u{e9}\u{20ac}"));
|
||||
assert_eq!(string("'a\\\nb';"), utf16("ab"));
|
||||
assert_eq!(string("'a\\\r\nb';"), utf16("ab"));
|
||||
assert_eq!(string(r"'\0';"), vec![0]);
|
||||
assert_eq!(string(r"'\101\7\08';"), vec![0x41, 7, 0, u16::from(b'8')]);
|
||||
assert_eq!(string(r"'\q\8';"), utf16("q8"));
|
||||
assert_eq!(string("'\u{2028}';"), vec![0x2028]);
|
||||
assert_eq!(string("'\u{1F600}';"), utf16("\u{1F600}"));
|
||||
assert!(err("'abc").message.contains("unterminated string"));
|
||||
assert!(err("'a\nb'").message.contains("unterminated string"));
|
||||
assert!(err(r"'\x4';").message.contains("hexadecimal escape"));
|
||||
assert!(err(r"'\u12';").message.contains("Unicode escape"));
|
||||
assert_eq!(string(r"'\u{41}\u{1F600}\u{0000E9}';"), utf16("A\u{1F600}\u{e9}"));
|
||||
for bad in [r"'\u{}';", r"'\u{110000}';", r"'\u{41';", r"'\u{4g}';"] {
|
||||
assert!(err(bad).message.contains("Unicode escape"), "{bad}");
|
||||
}
|
||||
assert!(strict_err(r"'\101';").message.contains("octal escape"));
|
||||
assert!(strict_err(r"'\8';").message.contains("octal escape"));
|
||||
ok(r"'use strict'; '\0';");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifiers_with_unicode_and_escapes() {
|
||||
let p = ok("var caf\\u00e9 = 1, \u{3b1}\u{3b2} = 2, $_x1 = 3, a\u{300} = 4;");
|
||||
let Stmt::Var(decls) = stmt(&p, p.body[0]) else { panic!() };
|
||||
let names: Vec<&str> = decls.iter().map(|d| d.name.name.as_str()).collect();
|
||||
assert_eq!(names, ["caf\u{e9}", "\u{3b1}\u{3b2}", "$_x1", "a\u{300}"]);
|
||||
let p = ok("\\u{61}b;");
|
||||
assert!(matches!(expr(&p, first_expr(&p)), Expr::Ident(n) if n == "ab"));
|
||||
assert!(err("var \\u0076ar = 1;").message.contains("escaped characters"));
|
||||
assert!(err("var a\\u0020b;").message.contains("identifier escape"));
|
||||
assert!(err("var \\x41;").message.contains("escape"));
|
||||
assert!(err("var a = 1 @ 2;").message.contains("unexpected character '@'"));
|
||||
assert!(err("var \u{2e2f};").message.contains("unexpected character"));
|
||||
assert!(err("var a\u{2e2f};").message.contains("unexpected character"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_characters_follow_their_unicode_categories() {
|
||||
// Letters (Lu, Ll, Lt, Lm, Lo, Nl) start an identifier; marks (Mn, Mc), decimal digits (Nd)
|
||||
// and connector punctuation (Pc) may follow.
|
||||
for good in [
|
||||
"var \u{995}\u{9cd}\u{9b7} = 1;",
|
||||
"var \u{b95}\u{bcd}\u{b95} = 1;",
|
||||
"var a\u{9be} = 1;",
|
||||
"var x\u{966} = 1;",
|
||||
"var a\u{203f}b = 1;",
|
||||
"var \u{2b0} = 1;",
|
||||
"var \u{2160} = 1;",
|
||||
"var \u{1c5} = 1;",
|
||||
"var \u{5d0}\u{5b7} = 1;",
|
||||
] {
|
||||
ok(good);
|
||||
}
|
||||
// Other numbers (No) are not identifier characters, and a mark cannot start an identifier.
|
||||
for bad in ["var a\u{b2} = 1;", "var x\u{bd} = 1;", "var \u{9be}a = 1;", "var \u{300}a = 1;", "var \u{966} = 1;"] {
|
||||
assert!(err(bad).message.contains("unexpected character"), "{bad:?}");
|
||||
}
|
||||
assert!(parse("var \\u09BEa = 1;", &Options::default()).is_err());
|
||||
assert!(parse("var a\\u00B2 = 1;", &Options::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_line_terminators() {
|
||||
let p = ok("a /* one\ntwo */ b");
|
||||
assert_eq!(p.body.len(), 2, "a comment with a line break separates statements");
|
||||
assert!(err("a /* one two */ b").message.contains("unexpected identifier 'b'"));
|
||||
assert!(err("/* open").message.contains("unterminated comment"));
|
||||
ok("// only a comment");
|
||||
ok("a\u{2028}b\u{2029}c\r\nd\re");
|
||||
ok("\u{feff}var\u{a0}a\u{3000}=\u{b}1\u{c};");
|
||||
}
|
||||
|
||||
// ---- regular expression literals ----
|
||||
|
||||
#[test]
|
||||
fn regular_expression_flags_and_patterns_are_checked() {
|
||||
for good in [
|
||||
"/a|b*c+?d{2}e{1,}f{1,3}?/",
|
||||
"/[a-z\\d-]/",
|
||||
"/[\\d-z]/",
|
||||
"/]/",
|
||||
"/{/",
|
||||
"/a{,}/",
|
||||
"/x{a}/",
|
||||
"/(?:a)(?=b)(?!c)/",
|
||||
"/(?=a)*/",
|
||||
"/\\1(a)/",
|
||||
"/[]/",
|
||||
"/[^]/",
|
||||
"/\\cJ\\c/",
|
||||
"/[\\b-\\n]/",
|
||||
"/a/gim",
|
||||
] {
|
||||
ok(&format!("x = {good};"));
|
||||
}
|
||||
for (bad, message) in [
|
||||
("/(/", "unterminated group"),
|
||||
("/a)/", "unmatched"),
|
||||
("/?/", "nothing to repeat"),
|
||||
("/a**/", "nothing to repeat"),
|
||||
("/^*/", "nothing to repeat"),
|
||||
("/{1}/", "nothing to repeat"),
|
||||
("/a{2,1}/", "out of order"),
|
||||
("/[z-a]/", "out of order"),
|
||||
("/(?<a>x)/", "invalid group"),
|
||||
("/[a/", "unterminated regular expression"),
|
||||
("/a\\/", "unterminated regular expression"),
|
||||
("/a\n/", "unterminated regular expression"),
|
||||
("/a/gg", "flags"),
|
||||
("/a/x", "flags"),
|
||||
("/a/\\u0067", "flags"),
|
||||
] {
|
||||
let e = err(&format!("x = {bad};"));
|
||||
assert!(e.message.contains(message), "{bad}: {}", e.message);
|
||||
assert_eq!((e.line, e.column), (1, 5), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- strict mode ----
|
||||
|
||||
#[test]
|
||||
fn use_strict_makes_the_rest_strict_mode_code() {
|
||||
assert!(ok("'use strict'; a;").strict);
|
||||
assert!(ok("\"use strict\"\na;").strict);
|
||||
assert!(!ok("'use\\x20strict'; a;").strict, "a directive with an escape is not \"use strict\"");
|
||||
assert!(!ok("('use strict'); a;").strict);
|
||||
assert!(!ok("a; 'use strict';").strict);
|
||||
assert!(ok("'other'; 'use strict'; a;").strict);
|
||||
assert!(!ok("'use strict' + 1;").strict);
|
||||
let p = ok("function f() { 'use strict'; } function g() {}");
|
||||
let funcs = &p.ast.functions;
|
||||
assert!(funcs[0].strict && !funcs[1].strict && !p.strict);
|
||||
assert!(parse("with (a) {}", &Options { strict: true, ..Options::default() }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_mode_early_errors() {
|
||||
assert!(strict_err("with (a) {}").message.contains("with statements"));
|
||||
assert!(strict_err("delete a;").message.contains("delete"));
|
||||
assert!(strict_err("delete ((a));").message.contains("delete"));
|
||||
ok("'use strict'; delete a.b; delete a[0];");
|
||||
for target in ["eval = 1;", "arguments++;", "--eval;", "arguments += 1;", "(eval) = 1;", "for (eval in o) {}"] {
|
||||
assert!(strict_err(target).message.contains("cannot assign"), "{target}");
|
||||
}
|
||||
for binding in ["var eval;", "function arguments() {}", "function f(eval) {}", "try {} catch (arguments) {}", "(function eval() {});"] {
|
||||
assert!(strict_err(binding).message.contains("cannot be declared"), "{binding}");
|
||||
}
|
||||
for word in ["implements", "interface", "let", "package", "private", "protected", "public", "static", "yield"] {
|
||||
ok(&format!("var {word} = 1;"));
|
||||
assert!(strict_err(&format!("var {word} = 1;")).message.contains("reserved word"), "{word}");
|
||||
assert!(strict_err(&format!("{word};")).message.contains("reserved word"), "{word}");
|
||||
}
|
||||
assert!(strict_err("function f(a, a) {}").message.contains("duplicate parameter"));
|
||||
ok("function f(a, a) {}");
|
||||
let e = strict_err("for (var x = 1 in o) {}");
|
||||
assert!(e.message.contains("initializer"), "{}", e.message);
|
||||
assert_eq!((e.line, e.column), (2, 16));
|
||||
ok("'use strict'; for (var x in o) {}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_function_made_strict_by_its_own_prologue_checks_its_name_and_parameters() {
|
||||
let e = err("function f(a, a) { 'use strict'; }");
|
||||
assert!(e.message.contains("duplicate parameter"));
|
||||
assert_eq!((e.line, e.column), (1, 15));
|
||||
assert!(err("function eval() { 'use strict'; }").message.contains("cannot be declared"));
|
||||
assert!(err("function f(arguments) { 'use strict'; }").message.contains("cannot be declared"));
|
||||
assert!(err("function static() { 'use strict'; }").message.contains("reserved word"));
|
||||
assert!(err("function f() { '\\01'; 'use strict'; }").message.contains("octal escape"));
|
||||
assert!(err("'\\01'; 'use strict';").message.contains("octal escape"));
|
||||
assert!(err("function f() { 'use strict'; 010 }").message.contains("octal literals"));
|
||||
assert!(err("function f() { 'use strict'; var public; }").message.contains("reserved word"));
|
||||
}
|
||||
|
||||
// ---- assignment targets ----
|
||||
|
||||
#[test]
|
||||
fn invalid_assignment_targets() {
|
||||
for bad in ["1 = 2;", "a + b = c;", "(a, b) = 1;", "a = 1 = 2;", "++1;", "1++;", "--a.b++;", "this = 1;", "'a' += 1;", "(a = 1) = 2;"] {
|
||||
let e = err(bad);
|
||||
assert!(e.message.contains("invalid"), "{bad}: {}", e.message);
|
||||
}
|
||||
ok("(a) = 1; a.b = 1; a[0] += 2; a.b++; --a[1]; (a.b) = c;");
|
||||
// Assigning to a call's result fails at run time outside strict mode code, and is a
|
||||
// SyntaxError in it.
|
||||
ok("f() = 1; f()++; for (f() in o) {}");
|
||||
for call in ["f() = 1;", "f() += 1;", "++f();", "f()--;", "(f()) = 1;", "for (f() in o) {}"] {
|
||||
assert!(strict_err(call).message.contains("invalid") || strict_err(call).message.contains("for-in"), "{call}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- preprocessor lines ----
|
||||
|
||||
#[test]
|
||||
fn preprocessor_lines_are_read_as_directives() {
|
||||
let src = "#target vectorcraft\n #include \"lib/a.jsxinc\"\n//@includepath \"~/x;~/y\"\n#strict on\n#unknown x\nvar a = 1;\n#targetengine main";
|
||||
let p = parse(src, &with_preprocessor()).unwrap();
|
||||
let names: Vec<(&str, u32, &str)> = p.directives.iter().map(|d| (d.name.as_str(), d.line, d.value.as_str())).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
("target", 1, "vectorcraft"),
|
||||
("include", 2, "\"lib/a.jsxinc\""),
|
||||
("includepath", 3, "\"~/x;~/y\""),
|
||||
("strict", 4, "on"),
|
||||
("unknown", 5, "x"),
|
||||
("targetengine", 7, "main"),
|
||||
]
|
||||
);
|
||||
assert_eq!(p.directives[2].form, "//@");
|
||||
assert!(p.directives[4].known.is_none());
|
||||
assert_eq!(p.body.len(), 1);
|
||||
assert!(parse(src, &Options::default()).is_err(), "without preprocessor lines a # is an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hash_away_from_the_start_of_a_line_is_an_error() {
|
||||
let e = parse("var a = 1; #include x", &with_preprocessor()).unwrap_err();
|
||||
assert!(e.message.contains("unexpected character '#'"));
|
||||
assert_eq!((e.line, e.column), (1, 12));
|
||||
assert!(parse("/* c */ #include x", &with_preprocessor()).is_err());
|
||||
// A comment naming no directive stays a comment.
|
||||
assert!(parse("//@other\n", &with_preprocessor()).unwrap().directives.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_directive_between_tokens_keeps_its_place_in_the_token_list() {
|
||||
let tokens = tokenize("a\n#include x\nb", &with_preprocessor()).unwrap();
|
||||
let kinds: Vec<String> = tokens
|
||||
.iter()
|
||||
.map(|t| match &t.kind {
|
||||
TokenKind::Ident(n) => n.clone(),
|
||||
TokenKind::Directive(d) => format!("#{}", d.name),
|
||||
TokenKind::Eof => "eof".into(),
|
||||
other => format!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kinds, ["a", "#include", "b", "eof"]);
|
||||
assert!(tokens[2].newline_before);
|
||||
}
|
||||
|
||||
// ---- positions ----
|
||||
|
||||
#[test]
|
||||
fn errors_carry_line_and_column() {
|
||||
let e = err("var a = 1;\nvar b = ];");
|
||||
assert_eq!((e.line, e.column, e.offset), (2, 9, 19));
|
||||
assert_eq!(e.to_string(), "SyntaxError: unexpected token ']' (line 2, column 9)");
|
||||
let e = err("a\r\nb\r\n 'x");
|
||||
assert_eq!((e.line, e.column), (3, 3));
|
||||
let e = err("a\u{2028} )");
|
||||
assert_eq!((e.line, e.column), (2, 3));
|
||||
let e = err("'\u{1F600}' )");
|
||||
assert_eq!((e.line, e.column), (1, 6), "columns count UTF-16 code units");
|
||||
let e = err("x = 'a\\\nb' )");
|
||||
assert_eq!((e.line, e.column), (2, 4), "a line continuation in a string counts its line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count_and_spans() {
|
||||
let p = ok("a;\r\nb;\nc;");
|
||||
assert_eq!(p.line_count(), 3);
|
||||
assert_eq!(ok("a;\n").line_count(), 1);
|
||||
assert_eq!(ok("").line_count(), 0);
|
||||
let span = p.ast.stmt_span(p.body[2]);
|
||||
assert_eq!(p.line_column(span.start), (3, 1));
|
||||
assert_eq!((span.start, span.end), (7, 9));
|
||||
}
|
||||
|
||||
// ---- nesting and limits ----
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_gives_a_syntax_error_not_a_stack_overflow() {
|
||||
const N: usize = 100_000;
|
||||
let cases = [
|
||||
format!("{}1{}", "(".repeat(N), ")".repeat(N)),
|
||||
format!("{}1{}", "[".repeat(N), "]".repeat(N)),
|
||||
format!("x = {}{}", "{a:".repeat(N), "}".repeat(N)),
|
||||
format!("{}{}", "{".repeat(N), "}".repeat(N)),
|
||||
format!("{}{}", "function f(){".repeat(N), "}".repeat(N)),
|
||||
format!("{}1", "a ? b : ".repeat(N)),
|
||||
format!("{}x{}", "f(".repeat(N), ")".repeat(N)),
|
||||
format!("{}x", "if (a) ".repeat(N)),
|
||||
];
|
||||
for src in &cases {
|
||||
let e = err(src);
|
||||
assert_eq!(e.message, TOO_DEEP, "{}", &src[..20]);
|
||||
assert_eq!(e.line, 1);
|
||||
assert!(e.column > 1);
|
||||
}
|
||||
let e = err(&format!("x = /{}a{}/;", "(".repeat(N), ")".repeat(N)));
|
||||
assert!(e.message.contains(TOO_DEEP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_up_to_the_limit_parses() {
|
||||
let depth = 50;
|
||||
ok(&format!("{}1{};", "(".repeat(depth), ")".repeat(depth)));
|
||||
ok(&format!("{}{}", "{".repeat(depth), "}".repeat(depth)));
|
||||
ok(&format!("x = {}{};", "[".repeat(depth), "]".repeat(depth)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_flat_runs_do_not_nest() {
|
||||
const N: usize = 100_000;
|
||||
ok(&format!("x = 1{};", " + 1".repeat(N)));
|
||||
ok(&format!("x = {}y;", "!".repeat(N)));
|
||||
ok(&format!("{}x = 1;", "a = ".repeat(N)));
|
||||
ok(&format!("if (a) b;{} else c;", " else if (a) b;".repeat(N)));
|
||||
ok(&format!("{}x;", (0..N).map(|i| format!("l{i}: ")).collect::<String>()));
|
||||
ok(&format!("x{};", ".y".repeat(N)));
|
||||
ok(&format!("x{};", "()".repeat(N)));
|
||||
ok(&"a;\n".repeat(N));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_label_chains_and_parameter_lists_are_checked_in_linear_time() {
|
||||
// Each check looks a name up in a hash set or map. Comparing every name with every earlier
|
||||
// one would take about 2 * 10^10 steps at this size.
|
||||
const N: usize = 200_000;
|
||||
let labels: String = (0..N).map(|i| format!("l{i}: ")).collect();
|
||||
let body: String = (0..N).map(|i| format!("m{i}: break l{i}; ")).collect();
|
||||
ok(&format!("{labels}for (;;) {{ {body}continue l0; }}"));
|
||||
assert!(err(&format!("{labels}l0: x;")).message.contains("duplicate label"));
|
||||
assert!(err(&format!("{labels}for (;;) {{ m: break l{N}; }}")).message.contains("undefined label"));
|
||||
let params = (0..N).map(|i| format!("p{i}")).collect::<Vec<_>>().join(", ");
|
||||
ok(&format!("function f({params}) {{ 'use strict'; }}"));
|
||||
assert!(err(&format!("function f({params}, p0) {{ 'use strict'; }}")).message.contains("duplicate parameter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_to_the_default_limit_fits_a_small_stack() {
|
||||
// The deepest nesting each production allows, parsed on a thread with a 1 MiB stack (the
|
||||
// stack a wasm build or a secondary thread may have).
|
||||
let n = crate::DEFAULT_MAX_DEPTH;
|
||||
let cases = [
|
||||
format!("x = {}1{};", "(".repeat(n), ")".repeat(n)),
|
||||
format!("x = {}1{};", "[".repeat(n), "]".repeat(n)),
|
||||
format!("x = {}1{};", "{a:".repeat(n), "}".repeat(n)),
|
||||
format!("{}{}", "function f(){".repeat(n), "}".repeat(n)),
|
||||
format!("{}x;", "if (a) ".repeat(n)),
|
||||
format!("x = {}1;", "a ? b : ".repeat(n)),
|
||||
format!("{}x{};", "f(".repeat(n), ")".repeat(n)),
|
||||
format!("{}x{};", "a[".repeat(n), "]".repeat(n)),
|
||||
];
|
||||
let handle = std::thread::Builder::new()
|
||||
.stack_size(1 << 20)
|
||||
.spawn(move || cases.iter().map(|src| parse(src, &Options::default()).map(|_| ()).map_err(|e| e.message)).collect::<Vec<_>>())
|
||||
.unwrap();
|
||||
for result in handle.join().unwrap() {
|
||||
// Each either parses or stops at the limit; neither overflows the stack.
|
||||
assert!(result.is_ok() || result == Err(TOO_DEEP.to_string()), "{result:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_over_the_length_limit_are_refused() {
|
||||
let options = Options { max_source_units: 10, ..Options::default() };
|
||||
assert_eq!(parse("a = 1;", &options).map(|_| ()), Ok(()));
|
||||
assert_eq!(parse("a = 1 + 2 + 3;", &options).unwrap_err().message, crate::TOO_LONG);
|
||||
assert_eq!(parse("'\u{e9}\u{e9}\u{e9}\u{e9}';", &options).map(|_| ()), Ok(()), "the limit counts UTF-16 code units");
|
||||
}
|
||||
|
||||
// ---- robustness ----
|
||||
|
||||
/// Fragments of the language, put together at random so the parser reaches its deeper states.
|
||||
const FRAGMENTS: &[&str] = &[
|
||||
"var ",
|
||||
"function ",
|
||||
"f",
|
||||
"(",
|
||||
")",
|
||||
"{",
|
||||
"}",
|
||||
"[",
|
||||
"]",
|
||||
";",
|
||||
",",
|
||||
"=",
|
||||
"+",
|
||||
"-",
|
||||
"/",
|
||||
"/=",
|
||||
"*",
|
||||
"?",
|
||||
":",
|
||||
"!",
|
||||
"++",
|
||||
"--",
|
||||
"if",
|
||||
"else",
|
||||
"for",
|
||||
"in",
|
||||
"while",
|
||||
"do",
|
||||
"return",
|
||||
"break",
|
||||
"continue",
|
||||
"new",
|
||||
"delete",
|
||||
"typeof",
|
||||
"this",
|
||||
"with",
|
||||
"switch",
|
||||
"case",
|
||||
"default",
|
||||
"try",
|
||||
"catch",
|
||||
"finally",
|
||||
"throw",
|
||||
"get",
|
||||
"set",
|
||||
"'s'",
|
||||
"\"\\x4",
|
||||
"1",
|
||||
"0x",
|
||||
"017",
|
||||
".5",
|
||||
"1e",
|
||||
"/re/g",
|
||||
"a:",
|
||||
"\n",
|
||||
" ",
|
||||
"'use strict';",
|
||||
"#include x\n",
|
||||
"//c\n",
|
||||
"/*",
|
||||
"*/",
|
||||
"\\u0061",
|
||||
"\u{2028}",
|
||||
"eval",
|
||||
"arguments",
|
||||
".",
|
||||
"x",
|
||||
];
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(512))]
|
||||
|
||||
#[test]
|
||||
fn arbitrary_text_never_panics(src in ".{0,200}") {
|
||||
let _ = parse(&src, &Options::default());
|
||||
let _ = parse(&src, &Options { strict: true, preprocessor_lines: true, ..Options::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_fragments_never_panic(parts in proptest::collection::vec(0..FRAGMENTS.len(), 0..80)) {
|
||||
let src: String = parts.iter().map(|&i| FRAGMENTS[i]).collect();
|
||||
for options in [Options::default(), Options { strict: true, preprocessor_lines: true, max_depth: 8, ..Options::default() }] {
|
||||
match parse(&src, &options) {
|
||||
Ok(p) => {
|
||||
for (_, span) in &p.ast.exprs {
|
||||
prop_assert!(span.start <= span.end && span.end as usize <= src.encode_utf16().count());
|
||||
}
|
||||
}
|
||||
Err(e) => prop_assert!(e.line >= 1 && e.column >= 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_utf16_never_panics(units in proptest::collection::vec(any::<u16>(), 0..120)) {
|
||||
let _ = crate::parse_utf16(&units, &Options::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
//! Tokens: what the lexer gives the parser.
|
||||
|
||||
/// A range of the source in UTF-16 code units, `start` included and `end` not.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Span {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
pub fn new(start: u32, end: u32) -> Self {
|
||||
Self { start, end }
|
||||
}
|
||||
|
||||
/// The span from the start of `self` to the end of `other`.
|
||||
pub fn to(self, other: Span) -> Span {
|
||||
Span { start: self.start, end: other.end.max(self.start) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The reserved words of ECMA-262 5.1 section 7.6.1 that are reserved in all code, with the
|
||||
/// literals `null`, `true` and `false`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Keyword {
|
||||
Break,
|
||||
Case,
|
||||
Catch,
|
||||
Class,
|
||||
Const,
|
||||
Continue,
|
||||
Debugger,
|
||||
Default,
|
||||
Delete,
|
||||
Do,
|
||||
Else,
|
||||
Enum,
|
||||
Export,
|
||||
Extends,
|
||||
False,
|
||||
Finally,
|
||||
For,
|
||||
Function,
|
||||
If,
|
||||
Import,
|
||||
In,
|
||||
Instanceof,
|
||||
New,
|
||||
Null,
|
||||
Return,
|
||||
Super,
|
||||
Switch,
|
||||
This,
|
||||
Throw,
|
||||
True,
|
||||
Try,
|
||||
Typeof,
|
||||
Var,
|
||||
Void,
|
||||
While,
|
||||
With,
|
||||
}
|
||||
|
||||
impl Keyword {
|
||||
/// The keyword spelled `s`, if `s` is one.
|
||||
pub fn from_word(s: &str) -> Option<Keyword> {
|
||||
use Keyword as K;
|
||||
Some(match s {
|
||||
"break" => K::Break,
|
||||
"case" => K::Case,
|
||||
"catch" => K::Catch,
|
||||
"class" => K::Class,
|
||||
"const" => K::Const,
|
||||
"continue" => K::Continue,
|
||||
"debugger" => K::Debugger,
|
||||
"default" => K::Default,
|
||||
"delete" => K::Delete,
|
||||
"do" => K::Do,
|
||||
"else" => K::Else,
|
||||
"enum" => K::Enum,
|
||||
"export" => K::Export,
|
||||
"extends" => K::Extends,
|
||||
"false" => K::False,
|
||||
"finally" => K::Finally,
|
||||
"for" => K::For,
|
||||
"function" => K::Function,
|
||||
"if" => K::If,
|
||||
"import" => K::Import,
|
||||
"in" => K::In,
|
||||
"instanceof" => K::Instanceof,
|
||||
"new" => K::New,
|
||||
"null" => K::Null,
|
||||
"return" => K::Return,
|
||||
"super" => K::Super,
|
||||
"switch" => K::Switch,
|
||||
"this" => K::This,
|
||||
"throw" => K::Throw,
|
||||
"true" => K::True,
|
||||
"try" => K::Try,
|
||||
"typeof" => K::Typeof,
|
||||
"var" => K::Var,
|
||||
"void" => K::Void,
|
||||
"while" => K::While,
|
||||
"with" => K::With,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
use Keyword as K;
|
||||
match self {
|
||||
K::Break => "break",
|
||||
K::Case => "case",
|
||||
K::Catch => "catch",
|
||||
K::Class => "class",
|
||||
K::Const => "const",
|
||||
K::Continue => "continue",
|
||||
K::Debugger => "debugger",
|
||||
K::Default => "default",
|
||||
K::Delete => "delete",
|
||||
K::Do => "do",
|
||||
K::Else => "else",
|
||||
K::Enum => "enum",
|
||||
K::Export => "export",
|
||||
K::Extends => "extends",
|
||||
K::False => "false",
|
||||
K::Finally => "finally",
|
||||
K::For => "for",
|
||||
K::Function => "function",
|
||||
K::If => "if",
|
||||
K::Import => "import",
|
||||
K::In => "in",
|
||||
K::Instanceof => "instanceof",
|
||||
K::New => "new",
|
||||
K::Null => "null",
|
||||
K::Return => "return",
|
||||
K::Super => "super",
|
||||
K::Switch => "switch",
|
||||
K::This => "this",
|
||||
K::Throw => "throw",
|
||||
K::True => "true",
|
||||
K::Try => "try",
|
||||
K::Typeof => "typeof",
|
||||
K::Var => "var",
|
||||
K::Void => "void",
|
||||
K::While => "while",
|
||||
K::With => "with",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The words reserved only in strict mode code (ECMA-262 5.1 section 7.6.1.2).
|
||||
pub fn strict_reserved(name: &str) -> bool {
|
||||
matches!(name, "implements" | "interface" | "let" | "package" | "private" | "protected" | "public" | "static" | "yield")
|
||||
}
|
||||
|
||||
/// The punctuators of ECMA-262 5.1 sections 7.7 and 7.8.5 (division).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Punct {
|
||||
LBrace,
|
||||
RBrace,
|
||||
LParen,
|
||||
RParen,
|
||||
LBracket,
|
||||
RBracket,
|
||||
Dot,
|
||||
Semicolon,
|
||||
Comma,
|
||||
Lt,
|
||||
Gt,
|
||||
LtEq,
|
||||
GtEq,
|
||||
EqEq,
|
||||
NotEq,
|
||||
EqEqEq,
|
||||
NotEqEq,
|
||||
Plus,
|
||||
Minus,
|
||||
Star,
|
||||
Percent,
|
||||
PlusPlus,
|
||||
MinusMinus,
|
||||
Shl,
|
||||
Shr,
|
||||
UShr,
|
||||
Amp,
|
||||
Pipe,
|
||||
Caret,
|
||||
Bang,
|
||||
Tilde,
|
||||
AmpAmp,
|
||||
PipePipe,
|
||||
Question,
|
||||
Colon,
|
||||
Eq,
|
||||
PlusEq,
|
||||
MinusEq,
|
||||
StarEq,
|
||||
PercentEq,
|
||||
ShlEq,
|
||||
ShrEq,
|
||||
UShrEq,
|
||||
AmpEq,
|
||||
PipeEq,
|
||||
CaretEq,
|
||||
Slash,
|
||||
SlashEq,
|
||||
}
|
||||
|
||||
impl Punct {
|
||||
/// Punctuators by spelling, longest first, so the first that matches is the longest.
|
||||
pub(crate) const ALL: &'static [(&'static str, Punct)] = &[
|
||||
(">>>=", Punct::UShrEq),
|
||||
("===", Punct::EqEqEq),
|
||||
("!==", Punct::NotEqEq),
|
||||
(">>>", Punct::UShr),
|
||||
("<<=", Punct::ShlEq),
|
||||
(">>=", Punct::ShrEq),
|
||||
("<=", Punct::LtEq),
|
||||
(">=", Punct::GtEq),
|
||||
("==", Punct::EqEq),
|
||||
("!=", Punct::NotEq),
|
||||
("++", Punct::PlusPlus),
|
||||
("--", Punct::MinusMinus),
|
||||
("<<", Punct::Shl),
|
||||
(">>", Punct::Shr),
|
||||
("&&", Punct::AmpAmp),
|
||||
("||", Punct::PipePipe),
|
||||
("+=", Punct::PlusEq),
|
||||
("-=", Punct::MinusEq),
|
||||
("*=", Punct::StarEq),
|
||||
("%=", Punct::PercentEq),
|
||||
("&=", Punct::AmpEq),
|
||||
("|=", Punct::PipeEq),
|
||||
("^=", Punct::CaretEq),
|
||||
("/=", Punct::SlashEq),
|
||||
("{", Punct::LBrace),
|
||||
("}", Punct::RBrace),
|
||||
("(", Punct::LParen),
|
||||
(")", Punct::RParen),
|
||||
("[", Punct::LBracket),
|
||||
("]", Punct::RBracket),
|
||||
(".", Punct::Dot),
|
||||
(";", Punct::Semicolon),
|
||||
(",", Punct::Comma),
|
||||
("<", Punct::Lt),
|
||||
(">", Punct::Gt),
|
||||
("+", Punct::Plus),
|
||||
("-", Punct::Minus),
|
||||
("*", Punct::Star),
|
||||
("%", Punct::Percent),
|
||||
("&", Punct::Amp),
|
||||
("|", Punct::Pipe),
|
||||
("^", Punct::Caret),
|
||||
("!", Punct::Bang),
|
||||
("~", Punct::Tilde),
|
||||
("?", Punct::Question),
|
||||
(":", Punct::Colon),
|
||||
("=", Punct::Eq),
|
||||
("/", Punct::Slash),
|
||||
];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
Punct::ALL.iter().find(|(_, p)| *p == self).map_or("", |(s, _)| s)
|
||||
}
|
||||
}
|
||||
|
||||
/// A preprocessor line's directive: `#include`, `#includepath`, `#script`, `#strict`, `#target`
|
||||
/// and `#targetengine`, as the public JavaScript Tools Guide lists them
|
||||
/// (<https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/>).
|
||||
/// Only the names are taken from it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum DirectiveName {
|
||||
Include,
|
||||
IncludePath,
|
||||
Script,
|
||||
Strict,
|
||||
Target,
|
||||
TargetEngine,
|
||||
}
|
||||
|
||||
impl DirectiveName {
|
||||
/// The directive named `name`, compared without case.
|
||||
pub fn from_name(name: &str) -> Option<DirectiveName> {
|
||||
const NAMES: [(&str, DirectiveName); 6] = [
|
||||
("include", DirectiveName::Include),
|
||||
("includepath", DirectiveName::IncludePath),
|
||||
("script", DirectiveName::Script),
|
||||
("strict", DirectiveName::Strict),
|
||||
("target", DirectiveName::Target),
|
||||
("targetengine", DirectiveName::TargetEngine),
|
||||
];
|
||||
NAMES.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, d)| *d)
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
DirectiveName::Include => "include",
|
||||
DirectiveName::IncludePath => "includepath",
|
||||
DirectiveName::Script => "script",
|
||||
DirectiveName::Strict => "strict",
|
||||
DirectiveName::Target => "target",
|
||||
DirectiveName::TargetEngine => "targetengine",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A preprocessor line: `#name value` at the start of a line, or a `//@name value` comment there
|
||||
/// that names a directive. It is read and kept, never run.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Directive {
|
||||
/// The directive, when the name is one of the six (`#name` lines with another name are kept
|
||||
/// with `None`).
|
||||
pub known: Option<DirectiveName>,
|
||||
/// The name as written.
|
||||
pub name: String,
|
||||
/// The text after the name, with blanks around it removed.
|
||||
pub value: String,
|
||||
/// `#` or `//@`.
|
||||
pub form: &'static str,
|
||||
pub span: Span,
|
||||
pub line: u32,
|
||||
}
|
||||
|
||||
/// What a token is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum TokenKind {
|
||||
/// The end of the source.
|
||||
Eof,
|
||||
/// An identifier, the strict-mode reserved words included. `escaped` tokens held a `\u` escape.
|
||||
Ident(String),
|
||||
Keyword(Keyword),
|
||||
Punct(Punct),
|
||||
/// A numeric literal's value.
|
||||
Number(f64),
|
||||
/// A string literal's value in UTF-16 code units.
|
||||
String(Vec<u16>),
|
||||
/// A regular expression literal: its pattern and flags as written.
|
||||
RegExp {
|
||||
pattern: Vec<u16>,
|
||||
flags: String,
|
||||
},
|
||||
/// A preprocessor line.
|
||||
Directive(Directive),
|
||||
}
|
||||
|
||||
/// A token, with where it is and the facts the grammar's rules need about it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Token {
|
||||
pub kind: TokenKind,
|
||||
pub span: Span,
|
||||
/// The line (from 1) and column (from 1, in UTF-16 code units) the token starts at.
|
||||
pub line: u32,
|
||||
pub column: u32,
|
||||
/// A line terminator comes between the previous token and this one (automatic semicolon
|
||||
/// insertion and the restricted productions use it).
|
||||
pub newline_before: bool,
|
||||
/// An identifier written with a `\u` escape.
|
||||
pub escaped: bool,
|
||||
/// A legacy octal numeric literal (`017`, `08`) or a string with an octal escape (`"\1"`), which
|
||||
/// strict mode code does not allow.
|
||||
pub legacy_octal: bool,
|
||||
}
|
||||
|
||||
impl Token {
|
||||
pub fn is_punct(&self, p: Punct) -> bool {
|
||||
self.kind == TokenKind::Punct(p)
|
||||
}
|
||||
|
||||
pub fn is_keyword(&self, k: Keyword) -> bool {
|
||||
self.kind == TokenKind::Keyword(k)
|
||||
}
|
||||
|
||||
/// The identifier's name, for an identifier token.
|
||||
pub fn ident(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
TokenKind::Ident(name) => Some(name),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The name this token gives when it stands where any identifier name may, as a property's
|
||||
/// name after `.` or in an object literal: identifiers, keywords and the literals `null`,
|
||||
/// `true` and `false`.
|
||||
pub fn identifier_name(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
TokenKind::Ident(name) => Some(name),
|
||||
TokenKind::Keyword(k) => Some(k.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The ES5 parse subset of test262, the ECMAScript conformance suite, when `cargo xtask test262`
|
||||
//! has fetched it (it sets `VECTORCRAFT_TEST262_DIR`; without it the test looks in
|
||||
//! `corpus/test262/` and skips when the suite is absent).
|
||||
//!
|
||||
//! The subset is every test under `test/language/` that names the ES5 section it was written for
|
||||
//! (`es5id:`) and has no `features:` tag (the tags name syntax and built-ins later than ES5) and no
|
||||
//! `module` flag. The other tests without a feature tag (written for later editions, many of them
|
||||
//! using later syntax) run too, and their counts are printed apart. A test whose metadata says
|
||||
//! `negative: phase: parse` must give a `SyntaxError`; every other test must parse. Each test runs
|
||||
//! as sloppy and as strict mode code (with `"use strict";` before it, as the suite's
|
||||
//! `INTERPRETING.md` says), unless its `onlyStrict`, `noStrict` or `raw` flag says otherwise. The
|
||||
//! test prints the counts and writes each failure to `failures.txt` beside the suite; it fails only
|
||||
//! when the suite cannot be read.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use vectorcraft_js::{Options, parse};
|
||||
|
||||
/// A test file's metadata, from its `/*--- … ---*/` block.
|
||||
#[derive(Default)]
|
||||
struct Meta {
|
||||
features: Vec<String>,
|
||||
flags: Vec<String>,
|
||||
negative_phase: Option<String>,
|
||||
/// The test names the ES5 section it was written for (`es5id:`).
|
||||
es5: bool,
|
||||
}
|
||||
|
||||
fn suite_dir() -> Option<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("VECTORCRAFT_TEST262_DIR") {
|
||||
return Some(PathBuf::from(dir));
|
||||
}
|
||||
let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/test262");
|
||||
let mut dirs: Vec<PathBuf> = std::fs::read_dir(corpus).ok()?.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.is_dir()).collect();
|
||||
dirs.sort();
|
||||
dirs.pop()
|
||||
}
|
||||
|
||||
/// The `.js` files under `dir`, sorted, with the `_FIXTURE` helper files left out.
|
||||
fn test_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&d) else { continue };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|e| e == "js") && !path.to_string_lossy().contains("_FIXTURE") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// A YAML flow list (`[a, b]`) or the block list (`- a` lines) after a key.
|
||||
fn list(value: &str, lines: &mut std::iter::Peekable<std::str::Lines<'_>>) -> Vec<String> {
|
||||
let value = value.trim();
|
||||
if let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) {
|
||||
return inner.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
while let Some(item) = lines.peek().and_then(|l| l.trim().strip_prefix("- ")) {
|
||||
out.push(item.trim().to_string());
|
||||
lines.next();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn meta(source: &str) -> Meta {
|
||||
let mut meta = Meta::default();
|
||||
let Some(block) = source.split_once("/*---").and_then(|(_, rest)| rest.split_once("---*/")).map(|(block, _)| block) else {
|
||||
return meta;
|
||||
};
|
||||
let mut lines = block.lines().peekable();
|
||||
let mut in_negative = false;
|
||||
while let Some(line) = lines.next() {
|
||||
let indented = line.starts_with(' ');
|
||||
let trimmed = line.trim();
|
||||
if !indented {
|
||||
in_negative = false;
|
||||
}
|
||||
if let Some(v) = trimmed.strip_prefix("features:").filter(|_| !indented) {
|
||||
meta.features = list(v, &mut lines);
|
||||
} else if let Some(v) = trimmed.strip_prefix("flags:").filter(|_| !indented) {
|
||||
meta.flags = list(v, &mut lines);
|
||||
} else if trimmed.starts_with("es5id:") && !indented {
|
||||
meta.es5 = true;
|
||||
} else if trimmed == "negative:" && !indented {
|
||||
in_negative = true;
|
||||
} else if let Some(v) = trimmed.strip_prefix("phase:").filter(|_| in_negative) {
|
||||
meta.negative_phase = Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn es5_parse_subset() {
|
||||
let Some(dir) = suite_dir() else {
|
||||
println!("test262: SKIPPED (no suite; run cargo xtask test262)");
|
||||
return;
|
||||
};
|
||||
let language = dir.join("test").join("language");
|
||||
assert!(language.is_dir(), "{} is not a test262 checkout", dir.display());
|
||||
let files = test_files(&language);
|
||||
assert!(!files.is_empty(), "no tests under {}", language.display());
|
||||
// VECTORCRAFT_TEST262_VERBOSE=1 prints each test before it is parsed.
|
||||
let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some();
|
||||
|
||||
let (mut positive, mut negative, mut with_features, mut modules) = (0usize, 0usize, 0usize, 0usize);
|
||||
let (mut passed, mut failed) = (0usize, 0usize);
|
||||
// The tests without a feature tag that name no ES5 section: files, runs passed and failed.
|
||||
let (mut later, mut later_passed, mut later_failed) = (0usize, 0usize, 0usize);
|
||||
let mut failures = String::new();
|
||||
let mut later_failures = String::new();
|
||||
for path in &files {
|
||||
let source = String::from_utf8_lossy(&std::fs::read(path).unwrap()).into_owned();
|
||||
let meta = meta(&source);
|
||||
if !meta.features.is_empty() {
|
||||
with_features += 1;
|
||||
continue;
|
||||
}
|
||||
if meta.flags.iter().any(|f| f == "module") || meta.negative_phase.as_deref() == Some("resolution") {
|
||||
modules += 1;
|
||||
continue;
|
||||
}
|
||||
let expect_error = meta.negative_phase.as_deref() == Some("parse");
|
||||
if !meta.es5 {
|
||||
later += 1;
|
||||
} else if expect_error {
|
||||
negative += 1;
|
||||
} else {
|
||||
positive += 1;
|
||||
}
|
||||
let has = |flag: &str| meta.flags.iter().any(|f| f == flag);
|
||||
let modes: &[bool] = if has("raw") || has("noStrict") {
|
||||
&[false]
|
||||
} else if has("onlyStrict") {
|
||||
&[true]
|
||||
} else {
|
||||
&[false, true]
|
||||
};
|
||||
for &strict in modes {
|
||||
if verbose {
|
||||
eprintln!("{} ({})", path.display(), if strict { "strict" } else { "sloppy" });
|
||||
}
|
||||
let text = if strict { format!("\"use strict\";\n{source}") } else { source.clone() };
|
||||
let result = parse(&text, &Options::default());
|
||||
let (pass, fail, list) =
|
||||
if meta.es5 { (&mut passed, &mut failed, &mut failures) } else { (&mut later_passed, &mut later_failed, &mut later_failures) };
|
||||
if result.is_err() == expect_error {
|
||||
*pass += 1;
|
||||
} else {
|
||||
*fail += 1;
|
||||
let rel = path.strip_prefix(&dir).unwrap_or(path).display().to_string().replace('\\', "/");
|
||||
let mode = if strict { "strict" } else { "sloppy" };
|
||||
let outcome = match &result {
|
||||
Ok(_) => "parsed; a SyntaxError was expected".to_string(),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
list.push_str(&format!("{rel} ({mode}): {outcome}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let report = dir.join("failures.txt");
|
||||
std::fs::write(&report, format!("# ES5 subset\n{failures}\n# Other tests without a feature tag\n{later_failures}")).unwrap();
|
||||
println!(
|
||||
"test262 ES5 parse subset ({}): {} tests ({positive} that must parse, {negative} that must give a SyntaxError), \
|
||||
{passed} runs passed, {failed} failed. Other tests without a feature tag: {later} tests, {later_passed} runs passed, \
|
||||
{later_failed} failed. Skipped {with_features} tests with feature tags and {modules} module tests. Failures: {}",
|
||||
dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
positive + negative,
|
||||
report.display()
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,11 @@ workspace = true
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# The SHA-256 of a module, which the install prompt shows and a grant records.
|
||||
sha2 = { version = "0.10", default-features = false }
|
||||
thiserror = { workspace = true }
|
||||
wasmi = { workspace = true }
|
||||
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//! What a plug-in may import from the host ([`IMPORTS`]), from plug-in API level 2 on:
|
||||
//! `vc_progress` reports how far a run has got, `vc_cancelled` tells it the user or a host limit
|
||||
//! asked it to stop, and `vc_log` writes a log line. A module may import them only when its
|
||||
//! manifest gives `api` 2 and lists the `progress` permission, and the host checks each import when
|
||||
//! the module is loaded ([`check_imports`], [`check_granted`]): one it doesn't offer, one with
|
||||
//! another signature and one the manifest doesn't allow are refused by name.
|
||||
//!
|
||||
//! Each call's arguments are checked: progress is clamped and passed on only when it changes (with
|
||||
//! a known total, to another tenth of a percent; past [`MAX_PROGRESS_REPORTS`], once per
|
||||
//! [`PROGRESS_STRIDE`] changes), log text must lie inside the module's memory and be UTF-8, and
|
||||
//! each line ([`MAX_LOG_LINE`]) and each run ([`MAX_LOG_LINES`], [`MAX_LOG_BYTES`],
|
||||
//! [`MAX_LOG_READ`]) are capped. Nothing a module passes can panic the host.
|
||||
|
||||
use wasmi::{Caller, Engine, Extern, ExternType, Linker, Module, StoreLimits, ValType};
|
||||
|
||||
use crate::manifest::Manifest;
|
||||
use crate::permissions::{self, PROGRESS_API, Permission};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The module a plug-in imports host functions from (the name a Rust or C toolchain gives
|
||||
/// `extern` functions by default).
|
||||
pub const MODULE: &str = "env";
|
||||
|
||||
/// What the host passes as the step's description with a plug-in's progress
|
||||
/// ([`vectorcraft_doc::cancel::progress`]), so a progress window shows a share rather than a count
|
||||
/// of artboards or files.
|
||||
pub const PROGRESS_STEP: &str = "plug-in";
|
||||
|
||||
/// Longest log line: its first 1,024 bytes of text, shown as at most 1,024 characters with hidden
|
||||
/// characters escaped. A longer one is cut and ends with `…`.
|
||||
pub const MAX_LOG_LINE: usize = 1024;
|
||||
|
||||
/// Most log lines one run writes; later ones are dropped, after one "log truncated" line.
|
||||
pub const MAX_LOG_LINES: usize = 200;
|
||||
|
||||
/// Most bytes of log text, as written with its escapes, one run writes; lines past it are dropped
|
||||
/// as past [`MAX_LOG_LINES`].
|
||||
pub const MAX_LOG_BYTES: usize = 32 * 1024;
|
||||
|
||||
/// Most bytes of text the host reads from a run's `vc_log` calls, checked or not: a call that
|
||||
/// would read past it is dropped as past [`MAX_LOG_LINES`].
|
||||
pub const MAX_LOG_READ: usize = 1024 * 1024;
|
||||
|
||||
/// Most progress reports one run passes on at the full rate; after them, one changed report in
|
||||
/// [`PROGRESS_STRIDE`] is, and so is the first after a total becomes known or unknown. The run's
|
||||
/// last report is always kept ([`Report::progress`]).
|
||||
pub const MAX_PROGRESS_REPORTS: usize = 2000;
|
||||
|
||||
/// Past [`MAX_PROGRESS_REPORTS`], how many changed reports go by for each one passed on.
|
||||
pub const PROGRESS_STRIDE: usize = 1000;
|
||||
|
||||
/// What `vc_log` returns: the line was written.
|
||||
pub const LOG_WRITTEN: i32 = 0;
|
||||
/// What `vc_log` returns: the run's log budget is spent, so the line was dropped.
|
||||
pub const LOG_DROPPED: i32 = 1;
|
||||
/// What `vc_log` returns: the text doesn't lie inside the module's memory.
|
||||
pub const LOG_OUTSIDE_MEMORY: i32 = -1;
|
||||
/// What `vc_log` returns: the text is not UTF-8.
|
||||
pub const LOG_NOT_UTF8: i32 = -2;
|
||||
/// What `vc_log` returns: the level is not 0 to 3.
|
||||
pub const LOG_BAD_LEVEL: i32 = -3;
|
||||
|
||||
/// One host import: its name and its type.
|
||||
pub struct Import {
|
||||
pub name: &'static str,
|
||||
params: &'static [ValType],
|
||||
results: &'static [ValType],
|
||||
/// Its type as WebAssembly text writes it.
|
||||
pub signature: &'static str,
|
||||
}
|
||||
|
||||
/// The host imports of API level 2, in module [`MODULE`]:
|
||||
///
|
||||
/// - `vc_progress(done: i64, total: i64)`: `done` of `total` steps are finished (`total` 0 or less:
|
||||
/// not known). Negative values count as 0 and `done` is clamped to `total`;
|
||||
/// - `vc_cancelled() -> i32`: 1 once the user cancelled the run or its time budget ran out, else 0;
|
||||
/// - `vc_log(level: i32, ptr: i32, len: i32) -> i32`: writes the UTF-8 text at `ptr` as a log line
|
||||
/// of `level` (0 error, 1 warning, 2 information, 3 debug) → [`LOG_WRITTEN`], [`LOG_DROPPED`],
|
||||
/// [`LOG_OUTSIDE_MEMORY`], [`LOG_NOT_UTF8`] or [`LOG_BAD_LEVEL`].
|
||||
pub const IMPORTS: [Import; 3] = [
|
||||
Import { name: "vc_progress", params: &[ValType::I64, ValType::I64], results: &[], signature: "(param i64 i64)" },
|
||||
Import { name: "vc_cancelled", params: &[], results: &[ValType::I32], signature: "(result i32)" },
|
||||
Import {
|
||||
name: "vc_log",
|
||||
params: &[ValType::I32, ValType::I32, ValType::I32],
|
||||
results: &[ValType::I32],
|
||||
signature: "(param i32 i32 i32) (result i32)",
|
||||
},
|
||||
];
|
||||
|
||||
/// A log line's level.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LogLevel {
|
||||
Error,
|
||||
Warn,
|
||||
Info,
|
||||
Debug,
|
||||
}
|
||||
|
||||
impl LogLevel {
|
||||
/// The level `vc_log`'s `level` names.
|
||||
fn from_code(code: i32) -> Option<LogLevel> {
|
||||
match code {
|
||||
0 => Some(LogLevel::Error),
|
||||
1 => Some(LogLevel::Warn),
|
||||
2 => Some(LogLevel::Info),
|
||||
3 => Some(LogLevel::Debug),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn log_level(self) -> log::Level {
|
||||
match self {
|
||||
LogLevel::Error => log::Level::Error,
|
||||
LogLevel::Warn => log::Level::Warn,
|
||||
LogLevel::Info => log::Level::Info,
|
||||
LogLevel::Debug => log::Level::Debug,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line a plug-in wrote with `vc_log`: its control and bidirectional formatting characters
|
||||
/// escaped ([`permissions::display_text`]), and cut to [`MAX_LOG_LINE`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogLine {
|
||||
pub level: LogLevel,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// The text of the line that stands for the lines a run's log budget dropped.
|
||||
pub const LOG_TRUNCATED: &str = "log truncated: later lines were dropped";
|
||||
|
||||
/// What a run reported through the host imports.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
/// Its last progress report, clamped: `(done, total)`, `total` 0 when not known.
|
||||
pub progress: Option<(u64, u64)>,
|
||||
/// The lines it wrote, then [`LOG_TRUNCATED`] once its budget dropped one.
|
||||
pub lines: Vec<LogLine>,
|
||||
/// Did its log budget drop a line?
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// When a run must stop (native: a wall-clock deadline).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Deadline {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Deadline {
|
||||
pub(crate) fn new(wall_time_ms: u64) -> Self {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = wall_time_ms;
|
||||
Self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant::now() + std::time::Duration::from_millis(wall_time_ms),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn passed(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::time::Instant::now() >= self.at {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn check(&self) -> Result<()> {
|
||||
if self.passed() { Err(Error::Limit("time budget".into())) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// One run's state that the host imports read and write.
|
||||
pub(crate) struct Run {
|
||||
/// The plug-in's id, which its log lines carry.
|
||||
id: String,
|
||||
/// Are its progress reports passed on ([`vectorcraft_doc::cancel::progress`])? A live
|
||||
/// effect's are not: it is drawn as part of something else, such as an export.
|
||||
forward: bool,
|
||||
/// Are its log lines written to the app's log? Not while the module is being loaded.
|
||||
logged: bool,
|
||||
deadline: Deadline,
|
||||
/// The last report passed on, as per mille of a known total (with 1) or the count of an
|
||||
/// unknown one (with 0), how many were, and how many changed ones went by since the last.
|
||||
last: Option<(u64, u64)>,
|
||||
reports: usize,
|
||||
skipped: usize,
|
||||
/// Bytes of log text written (with their escapes) and read.
|
||||
log_bytes: usize,
|
||||
log_read: usize,
|
||||
pub(crate) report: Report,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
pub(crate) fn new(id: &str, forward: bool, logged: bool, deadline: Deadline) -> Self {
|
||||
Run {
|
||||
id: id.to_string(),
|
||||
forward,
|
||||
logged,
|
||||
deadline,
|
||||
last: None,
|
||||
reports: 0,
|
||||
skipped: 0,
|
||||
log_bytes: 0,
|
||||
log_read: 0,
|
||||
report: Report::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn progress(&mut self, done: i64, total: i64) {
|
||||
let total = u64::try_from(total).unwrap_or(0);
|
||||
let done = u64::try_from(done).unwrap_or(0);
|
||||
let done = if total > 0 { done.min(total) } else { done };
|
||||
self.report.progress = Some((done, total));
|
||||
let key = if total > 0 { ((u128::from(done) * 1000 / u128::from(total)) as u64, 1) } else { (done, 0) };
|
||||
if !self.forward || self.last == Some(key) {
|
||||
return;
|
||||
}
|
||||
let switched = self.last.is_some_and(|(_, t)| (t > 0) != (total > 0));
|
||||
if self.reports >= MAX_PROGRESS_REPORTS && !switched {
|
||||
self.skipped += 1;
|
||||
if self.skipped < PROGRESS_STRIDE {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.skipped = 0;
|
||||
self.last = Some(key);
|
||||
self.reports += 1;
|
||||
vectorcraft_doc::cancel::progress(done, total, PROGRESS_STEP);
|
||||
}
|
||||
|
||||
fn cancelled(&self) -> i32 {
|
||||
i32::from(vectorcraft_doc::cancel::is_cancelled() || self.deadline.passed())
|
||||
}
|
||||
|
||||
/// Writes the `len` bytes at `ptr` in `memory` as a line of `level` → what `vc_log` returns.
|
||||
fn log(&mut self, level: i32, memory: &[u8], ptr: usize, len: usize) -> i32 {
|
||||
let Some(level) = LogLevel::from_code(level) else { return LOG_BAD_LEVEL };
|
||||
let Some(bytes) = ptr.checked_add(len).and_then(|end| memory.get(ptr..end)) else { return LOG_OUTSIDE_MEMORY };
|
||||
if self.report.truncated {
|
||||
return LOG_DROPPED;
|
||||
}
|
||||
if self.log_read.saturating_add(len) > MAX_LOG_READ {
|
||||
return self.truncate();
|
||||
}
|
||||
self.log_read += len;
|
||||
let Ok(text) = std::str::from_utf8(bytes) else { return LOG_NOT_UTF8 };
|
||||
// A character the cut splits is left out.
|
||||
let head = text.get(..text.floor_char_boundary(MAX_LOG_LINE)).unwrap_or(text);
|
||||
let shown = if head.len() < text.len() {
|
||||
let mut shown = permissions::display_text(head, MAX_LOG_LINE - 1);
|
||||
if !shown.ends_with('…') {
|
||||
shown.push('…');
|
||||
}
|
||||
shown
|
||||
} else {
|
||||
permissions::display_text(head, MAX_LOG_LINE)
|
||||
};
|
||||
if self.report.lines.len() >= MAX_LOG_LINES || self.log_bytes + shown.len() > MAX_LOG_BYTES {
|
||||
return self.truncate();
|
||||
}
|
||||
self.log_bytes += shown.len();
|
||||
self.write(level, shown);
|
||||
LOG_WRITTEN
|
||||
}
|
||||
|
||||
/// Drops the line and the run's later ones, after one line that says so → [`LOG_DROPPED`].
|
||||
fn truncate(&mut self) -> i32 {
|
||||
self.report.truncated = true;
|
||||
self.write(LogLevel::Warn, LOG_TRUNCATED.to_string());
|
||||
LOG_DROPPED
|
||||
}
|
||||
|
||||
fn write(&mut self, level: LogLevel, text: String) {
|
||||
if self.logged {
|
||||
log::log!(target: "vectorcraft_plugins", level.log_level(), "{}: {text}", self.id);
|
||||
}
|
||||
self.report.lines.push(LogLine { level, text });
|
||||
}
|
||||
}
|
||||
|
||||
/// What a store holds: its memory limits and the run's state.
|
||||
pub(crate) struct Host {
|
||||
pub(crate) limits: StoreLimits,
|
||||
pub(crate) run: Run,
|
||||
}
|
||||
|
||||
/// A linker that offers the host imports when `granted`, and no import otherwise.
|
||||
pub(crate) fn linker(engine: &Engine, granted: bool) -> Linker<Host> {
|
||||
let mut linker = Linker::<Host>::new(engine);
|
||||
if granted {
|
||||
// Each name is defined once, so none of these fails.
|
||||
let _ = linker.func_wrap(MODULE, "vc_progress", |mut caller: Caller<'_, Host>, done: i64, total: i64| {
|
||||
caller.data_mut().run.progress(done, total);
|
||||
});
|
||||
let _ = linker.func_wrap(MODULE, "vc_cancelled", |caller: Caller<'_, Host>| -> i32 { caller.data().run.cancelled() });
|
||||
let _ = linker.func_wrap(MODULE, "vc_log", |mut caller: Caller<'_, Host>, level: i32, ptr: i32, len: i32| -> i32 {
|
||||
let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return LOG_OUTSIDE_MEMORY };
|
||||
let (data, host) = memory.data_and_store_mut(&mut caller);
|
||||
host.run.log(level, data, ptr as u32 as usize, len as u32 as usize)
|
||||
});
|
||||
}
|
||||
linker
|
||||
}
|
||||
|
||||
/// Checks that every import of `module` is a host import with the host's signature → their names.
|
||||
/// Any other import is refused, and the error names it.
|
||||
pub(crate) fn check_imports(module: &Module) -> Result<Vec<&'static str>> {
|
||||
let mut names = Vec::new();
|
||||
for imp in module.imports() {
|
||||
let offered = if imp.module() == MODULE { IMPORTS.iter().find(|i| i.name == imp.name()) } else { None };
|
||||
let (from, name) = (permissions::display_text(imp.module(), 64), permissions::display_text(imp.name(), 64));
|
||||
let Some(host) = offered else {
|
||||
return Err(Error::Abi(format!(
|
||||
"the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network"
|
||||
)));
|
||||
};
|
||||
match imp.ty() {
|
||||
ExternType::Func(f) if f.params() == host.params && f.results() == host.results => names.push(host.name),
|
||||
_ => {
|
||||
let signature = host.signature;
|
||||
return Err(Error::Abi(format!("the module imports `env.{name}` with another type than the host's: it is {signature}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Checks that `manifest` allows the host imports `imports`: `api` 2 and the `progress`
|
||||
/// permission. The error names the first import it doesn't allow.
|
||||
pub(crate) fn check_granted(manifest: &Manifest, imports: &[&str]) -> Result<()> {
|
||||
match imports.first() {
|
||||
Some(name) if manifest.api < PROGRESS_API || !manifest.permissions().contains(&Permission::Progress) => Err(Error::Abi(format!(
|
||||
"the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress`"
|
||||
))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run() -> Run {
|
||||
Run::new("test", true, false, Deadline::new(60_000))
|
||||
}
|
||||
|
||||
/// Progress is clamped: negative counts are 0 and `done` never passes a known total.
|
||||
#[test]
|
||||
fn progress_is_clamped() {
|
||||
let mut r = run();
|
||||
for ((done, total), want) in
|
||||
[((5, 3), (3, 3)), ((-4, 10), (0, 10)), ((7, -1), (7, 0)), ((i64::MAX, i64::MAX), (i64::MAX as u64, i64::MAX as u64))]
|
||||
{
|
||||
r.progress(done, total);
|
||||
assert_eq!(r.report.progress, Some(want), "{done} of {total}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports are passed on only when they change (with a known total, to another tenth of a
|
||||
/// percent, however the total changes; with an unknown total, each change of `done`), and past
|
||||
/// [`MAX_PROGRESS_REPORTS`] once per [`PROGRESS_STRIDE`] changes, except the first after the
|
||||
/// total becomes known; the last one is always kept.
|
||||
#[test]
|
||||
fn progress_is_passed_on_at_a_bounded_rate() {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = {
|
||||
let seen = seen.clone();
|
||||
Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string())))
|
||||
};
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
let mut r = run();
|
||||
vectorcraft_doc::cancel::scope(&token, Some(sink), || {
|
||||
for done in 0..=1_000_000 {
|
||||
r.progress(done, 1_000_000);
|
||||
}
|
||||
});
|
||||
let seen = seen.borrow();
|
||||
assert_eq!(seen.len(), 1001, "one per tenth of a percent");
|
||||
assert_eq!(seen.last(), Some(&(1_000_000, 1_000_000, PROGRESS_STEP.to_string())));
|
||||
let mut unknown = run();
|
||||
let later = Rc::new(RefCell::new(Vec::new()));
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = {
|
||||
let later = later.clone();
|
||||
Rc::new(move |d, t, _| later.borrow_mut().push((d, t)))
|
||||
};
|
||||
vectorcraft_doc::cancel::scope(&token, Some(sink), || {
|
||||
for done in 0..10_000 {
|
||||
unknown.progress(done, 0);
|
||||
}
|
||||
assert_eq!(unknown.reports, MAX_PROGRESS_REPORTS + (10_000 - MAX_PROGRESS_REPORTS) / PROGRESS_STRIDE);
|
||||
assert_eq!(unknown.report.progress, Some((9_999, 0)), "the last report is kept");
|
||||
// A total that becomes known is passed on at once, and the reports after it keep
|
||||
// coming at the slower rate.
|
||||
unknown.progress(10, 100);
|
||||
assert_eq!(later.borrow().last(), Some(&(10, 100)));
|
||||
for done in 11..=100 {
|
||||
unknown.progress(done, 100);
|
||||
}
|
||||
});
|
||||
assert_eq!(later.borrow().last(), Some(&(10, 100)), "90 changes are fewer than the stride");
|
||||
assert_eq!(unknown.report.progress, Some((100, 100)));
|
||||
// A new total at the same tenth of a percent is not a new step.
|
||||
let mut retotalled = run();
|
||||
let steps = Rc::new(RefCell::new(Vec::new()));
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = {
|
||||
let steps = steps.clone();
|
||||
Rc::new(move |d, t, _| steps.borrow_mut().push((d, t)))
|
||||
};
|
||||
vectorcraft_doc::cancel::scope(&token, Some(sink), || {
|
||||
for total in 10..2_010 {
|
||||
retotalled.progress(total / 10, total - total % 10);
|
||||
}
|
||||
});
|
||||
assert_eq!(*steps.borrow(), [(1, 10)]);
|
||||
assert_eq!(retotalled.report.progress, Some((200, 2_000)));
|
||||
}
|
||||
|
||||
/// Text outside memory, text that isn't UTF-8 and an unknown level are errors; a long line is
|
||||
/// cut, also inside a character, and hidden characters are escaped.
|
||||
#[test]
|
||||
fn log_text_is_checked_and_cut() {
|
||||
let mut r = run();
|
||||
let memory = b"hello\xff\xfe\nworld";
|
||||
assert_eq!(r.log(2, memory, 0, 5), LOG_WRITTEN);
|
||||
assert_eq!(r.log(2, memory, 0, memory.len() + 1), LOG_OUTSIDE_MEMORY);
|
||||
assert_eq!(r.log(2, memory, usize::MAX, 2), LOG_OUTSIDE_MEMORY);
|
||||
assert_eq!(r.log(2, memory, 4, 3), LOG_NOT_UTF8);
|
||||
assert_eq!(r.log(4, memory, 0, 5), LOG_BAD_LEVEL);
|
||||
assert_eq!(r.log(-1, memory, 0, 5), LOG_BAD_LEVEL);
|
||||
assert_eq!(r.log(0, memory, 7, 6), LOG_WRITTEN);
|
||||
// The cut falls inside the 512th "é", which is left out.
|
||||
let long = format!("a{}", "é".repeat(MAX_LOG_LINE));
|
||||
assert_eq!(r.log(1, long.as_bytes(), 0, long.len()), LOG_WRITTEN);
|
||||
let lines = &r.report.lines;
|
||||
assert_eq!(lines[0], LogLine { level: LogLevel::Info, text: "hello".into() });
|
||||
assert_eq!(lines[1], LogLine { level: LogLevel::Error, text: "\\u{000A}world".into() });
|
||||
assert_eq!(lines[2], LogLine { level: LogLevel::Warn, text: format!("a{}…", "é".repeat(MAX_LOG_LINE / 2 - 1)) });
|
||||
assert!(!r.report.truncated);
|
||||
// Text past the cut is checked too.
|
||||
let bad_tail = [vec![b'a'; MAX_LOG_LINE * 2], vec![0xff]].concat();
|
||||
assert_eq!(r.log(2, &bad_tail, 0, bad_tail.len()), LOG_NOT_UTF8);
|
||||
// A cut line of escapes is at most MAX_LOG_LINE characters, its ellipsis included.
|
||||
let controls = vec![1u8; MAX_LOG_LINE * 2];
|
||||
assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_WRITTEN);
|
||||
let text = &r.report.lines[3].text;
|
||||
assert!(text.starts_with("\\u{0001}") && text.ends_with('…') && text.chars().count() <= MAX_LOG_LINE, "{text}");
|
||||
}
|
||||
|
||||
/// The byte budget counts a line as written, with its escapes, so short lines of hidden
|
||||
/// characters can't write more than [`MAX_LOG_BYTES`].
|
||||
#[test]
|
||||
fn log_budget_counts_escaped_text() {
|
||||
let mut r = run();
|
||||
let controls = [1u8; 100];
|
||||
let shown = "\\u{0001}".len() * controls.len();
|
||||
let fit = MAX_LOG_BYTES / shown;
|
||||
for i in 0..fit {
|
||||
assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_WRITTEN, "line {i}");
|
||||
}
|
||||
assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_DROPPED);
|
||||
let written: usize = r.report.lines.iter().filter(|l| l.text != LOG_TRUNCATED).map(|l| l.text.len()).sum();
|
||||
assert!(written <= MAX_LOG_BYTES, "{written}");
|
||||
}
|
||||
|
||||
/// The host reads at most [`MAX_LOG_READ`] bytes of log text in a run, so long texts can't
|
||||
/// make it check more; once past it, later calls are dropped without reading.
|
||||
#[test]
|
||||
fn log_text_read_is_bounded() {
|
||||
let mut r = run();
|
||||
let big = vec![b'a'; MAX_LOG_READ / 4];
|
||||
for _ in 0..4 {
|
||||
assert_eq!(r.log(2, &big, 0, big.len()), LOG_WRITTEN);
|
||||
}
|
||||
assert_eq!(r.log(2, b"x", 0, 1), LOG_DROPPED);
|
||||
assert!(r.report.truncated);
|
||||
let mut huge = run();
|
||||
let over = vec![0xffu8; MAX_LOG_READ + 1];
|
||||
assert_eq!(huge.log(2, &over, 0, over.len()), LOG_DROPPED, "dropped before it is checked");
|
||||
assert_eq!(huge.log(2, b"x", 0, 1), LOG_DROPPED);
|
||||
}
|
||||
|
||||
/// Lines past the run's budget are dropped, with one line that says so.
|
||||
#[test]
|
||||
fn log_lines_past_the_budget_are_dropped_with_one_truncation_line() {
|
||||
let mut r = run();
|
||||
for i in 0..MAX_LOG_LINES + 50 {
|
||||
let want = if i < MAX_LOG_LINES { LOG_WRITTEN } else { LOG_DROPPED };
|
||||
assert_eq!(r.log(2, b"line", 0, 4), want, "line {i}");
|
||||
}
|
||||
assert_eq!(r.report.lines.len(), MAX_LOG_LINES + 1);
|
||||
assert_eq!(r.report.lines.last(), Some(&LogLine { level: LogLevel::Warn, text: LOG_TRUNCATED.into() }));
|
||||
assert!(r.report.truncated);
|
||||
// So is text past the run's bytes.
|
||||
let mut bytes = run();
|
||||
let big = vec![b'x'; MAX_LOG_LINE];
|
||||
let fit = MAX_LOG_BYTES / MAX_LOG_LINE;
|
||||
for _ in 0..fit {
|
||||
assert_eq!(bytes.log(2, &big, 0, big.len()), LOG_WRITTEN);
|
||||
}
|
||||
assert_eq!(bytes.log(2, b"x", 0, 1), LOG_DROPPED);
|
||||
assert_eq!(bytes.report.lines.len(), fit + 1);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
//! A plug-in is a WebAssembly module that implements the VectorCraft plug-in ABI (v1, documented
|
||||
//! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with:
|
||||
//!
|
||||
//! - **no host imports**: no WASI, file system, network, clock or randomness. A module that
|
||||
//! imports anything is rejected;
|
||||
//! - **no host imports but the three of API level 2** ([`host`]: progress, Cancel and log lines,
|
||||
//! with the `progress` permission): no WASI, file system, network, clock or randomness. A module
|
||||
//! that imports anything else is rejected;
|
||||
//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is
|
||||
//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a
|
||||
//! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it;
|
||||
@@ -21,19 +22,23 @@
|
||||
//! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole
|
||||
//! document in a file format of its own. The process-wide [`registry`] holds the installed
|
||||
//! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show
|
||||
//! them well: labels, units, groups, help and conditions ([`dialog`]).
|
||||
//! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is
|
||||
//! the set of [`permissions`] its manifest lists, which the user approves when it is installed.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod dialog;
|
||||
pub mod effect;
|
||||
pub mod export;
|
||||
pub mod host;
|
||||
pub mod manifest;
|
||||
pub mod objects;
|
||||
pub mod permissions;
|
||||
pub mod registry;
|
||||
mod runtime;
|
||||
pub mod wat;
|
||||
|
||||
pub use dialog::ParamUi;
|
||||
pub use host::{LogLevel, LogLine, Report};
|
||||
pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec};
|
||||
pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::dialog::ParamUi;
|
||||
use crate::permissions::{self, API_LEVEL, PROGRESS_API, Permission};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Longest manifest the host reads.
|
||||
@@ -154,6 +155,12 @@ fn text_max() -> usize {
|
||||
fn list_max() -> usize {
|
||||
16
|
||||
}
|
||||
fn first_api() -> u32 {
|
||||
1
|
||||
}
|
||||
fn is_first_api(api: &u32) -> bool {
|
||||
*api == 1
|
||||
}
|
||||
|
||||
/// One declared parameter: what it holds, and how dialogs show it. In the manifest both are keys of
|
||||
/// the same object: `{"type": "number", "min": 0, "max": 10, "default": 1, "unit": "mm"}`.
|
||||
@@ -236,6 +243,13 @@ pub struct Manifest {
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub author: String,
|
||||
/// The plug-in API level it was built for, 1 to [`API_LEVEL`] (1 when left out).
|
||||
#[serde(default = "first_api", skip_serializing_if = "is_first_api")]
|
||||
pub api: u32,
|
||||
/// The permissions it asks for ([`crate::permissions`]), which the user approves when it is
|
||||
/// installed. Left out, it gets the ones its kind implies ([`Permission::implied`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permissions: Option<Vec<String>>,
|
||||
/// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest.
|
||||
#[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")]
|
||||
pub params: Vec<(String, Param)>,
|
||||
@@ -269,6 +283,8 @@ impl Manifest {
|
||||
kind: Kind::Filter,
|
||||
description: String::new(),
|
||||
author: String::new(),
|
||||
api: 1,
|
||||
permissions: None,
|
||||
params: Vec::new(),
|
||||
extensions: Vec::new(),
|
||||
budget: Budget::Light,
|
||||
@@ -288,6 +304,15 @@ impl Manifest {
|
||||
if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 {
|
||||
return Err(Error::Manifest("`version`, `description` or `author` is too long".into()));
|
||||
}
|
||||
if !(1..=API_LEVEL).contains(&self.api) {
|
||||
return Err(Error::Manifest(format!("`api` is {}; this host implements plug-in API levels 1 to {API_LEVEL}", self.api)));
|
||||
}
|
||||
if let Some(names) = &self.permissions
|
||||
&& permissions::validate(self.kind, names)?.contains(&Permission::Progress)
|
||||
&& self.api < PROGRESS_API
|
||||
{
|
||||
return Err(Error::Manifest(format!("permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}")));
|
||||
}
|
||||
self.validate_extensions()?;
|
||||
match (self.budget, self.kind) {
|
||||
(Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {}
|
||||
@@ -363,6 +388,19 @@ impl Manifest {
|
||||
self.params.iter().map(|(k, p)| (k.clone(), p.spec.default_value())).collect()
|
||||
}
|
||||
|
||||
/// What the plug-in may do: the permissions it lists, else the ones its kind implies.
|
||||
pub fn permissions(&self) -> Vec<Permission> {
|
||||
match &self.permissions {
|
||||
Some(names) => permissions::validate(self.kind, names).unwrap_or_default(),
|
||||
None => Permission::implied(self.kind).to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does installing it need the user's approval: does it list permissions?
|
||||
pub fn asks_permissions(&self) -> bool {
|
||||
self.permissions.as_ref().is_some_and(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// Validates user `params` against the schema: missing keys take their defaults, numbers are
|
||||
/// clamped into range (whole numbers rounded), unknown keys are dropped; a value of the wrong
|
||||
/// type is an error.
|
||||
@@ -595,6 +633,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A manifest lists the permissions it asks for, checked against its kind; without the key it
|
||||
/// gets the ones its kind implies, as plug-ins did before manifests listed them, and needs no
|
||||
/// approval. `api` is 1 when left out, and a later level is refused.
|
||||
#[test]
|
||||
fn permissions_and_api_level() {
|
||||
let listed = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["doc.write","doc.read"]}"#).unwrap();
|
||||
assert_eq!(listed.permissions(), [Permission::DocRead, Permission::DocWrite]);
|
||||
assert!(listed.asks_permissions());
|
||||
assert_eq!(serde_json::to_value(&listed).unwrap()["permissions"], json!(["doc.write", "doc.read"]));
|
||||
assert!(serde_json::to_value(&listed).unwrap().get("api").is_none(), "level 1 isn't written back");
|
||||
let plain = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap();
|
||||
assert_eq!((plain.api, plain.asks_permissions()), (1, false));
|
||||
assert_eq!(plain.permissions(), [Permission::DocRead, Permission::DocWrite, Permission::State]);
|
||||
assert!(serde_json::to_value(&plain).unwrap().get("permissions").is_none());
|
||||
let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect"}"#).unwrap();
|
||||
assert_eq!(effect.permissions(), [Permission::DocRead, Permission::EffectRender]);
|
||||
let exporter = Manifest::parse(br#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"]}"#).unwrap();
|
||||
assert_eq!(exporter.permissions(), [Permission::DocRead, Permission::ExportWrite]);
|
||||
assert!(!Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","permissions":[]}"#).unwrap().asks_permissions());
|
||||
// Level 2 adds `progress`, which only a level-2 manifest may list.
|
||||
let level2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["progress","doc.read"]}"#).unwrap();
|
||||
assert_eq!((level2.api, level2.permissions()), (2, vec![Permission::DocRead, Permission::Progress]));
|
||||
assert_eq!(serde_json::to_value(&level2).unwrap()["api"], json!(2));
|
||||
let plain2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2}"#).unwrap();
|
||||
assert!(!plain2.permissions().contains(&Permission::Progress), "never implied");
|
||||
for (bad, says) in [
|
||||
(r#"{"id":"a","name":"x","kind":"filter","permissions":["doc.read","net.fetch"]}"#, "unknown permission `net.fetch`"),
|
||||
(r#"{"id":"a","name":"x","kind":"filter","permissions":["ui.panel"]}"#, "reserved"),
|
||||
(r#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read"]}"#, "effect.render"),
|
||||
(r#"{"id":"a","name":"x","kind":"filter","permissions":"doc.read"}"#, "manifest"),
|
||||
(r#"{"id":"a","name":"x","kind":"filter","permissions":["progress"]}"#, "`progress` needs plug-in API level 2"),
|
||||
(r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["progress"]}"#, "`progress` needs plug-in API level 2"),
|
||||
(r#"{"id":"a","name":"x","kind":"filter","api":3}"#, "`api` is 3"),
|
||||
(r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"),
|
||||
(r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"),
|
||||
] {
|
||||
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "{bad}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_and_number_lists_resolve_like_the_other_types() {
|
||||
let m = Manifest::parse(
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! What a plug-in may do: the permissions its manifest lists (`permissions`), which the user
|
||||
//! approves when it is installed ([`Grant`], kept in [`crate::registry`]), and which the host
|
||||
//! enforces on every run.
|
||||
//!
|
||||
//! `doc.read`, `doc.write`, `state` and `progress` are the names of the plug-in capability model in
|
||||
//! storytold/photocraft#662; the other names are VectorCraft's own. A manifest without
|
||||
//! `permissions` gets the ones its kind implies ([`Permission::implied`]): what every plug-in of
|
||||
//! that kind could do before manifests listed them. `progress`, which lets a plug-in use the host
|
||||
//! imports of API level 2 ([`crate::host`]), is never implied: a manifest lists it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, Kind, Result};
|
||||
|
||||
/// The plug-in API level this host implements. A manifest's `api` (1 when left out) may be at most
|
||||
/// this.
|
||||
pub const API_LEVEL: u32 = 2;
|
||||
|
||||
/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress`
|
||||
/// permission they need.
|
||||
pub const PROGRESS_API: u32 = 2;
|
||||
|
||||
/// Most permissions a manifest may list.
|
||||
pub const MAX_PERMISSIONS: usize = 16;
|
||||
|
||||
/// Longest plug-in name a prompt shows, in characters.
|
||||
pub const MAX_SHOWN_NAME: usize = 64;
|
||||
|
||||
/// Longest version or id a prompt shows, in characters.
|
||||
pub const MAX_SHOWN_FIELD: usize = 64;
|
||||
|
||||
/// Names kept for host calls a later plug-in API adds. This host grants none of them, so a
|
||||
/// manifest that lists one is refused when it is installed.
|
||||
pub const RESERVED: &[&str] = &["ui.panel", "ui.tool"];
|
||||
|
||||
/// One thing a plug-in may do.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum Permission {
|
||||
/// Read the objects the host hands it: a filter's selected paths, the object a live effect is
|
||||
/// on, the document an exporter writes.
|
||||
#[serde(rename = "doc.read")]
|
||||
DocRead,
|
||||
/// Change, add and delete objects (a filter's output), as one undo step.
|
||||
#[serde(rename = "doc.write")]
|
||||
DocWrite,
|
||||
/// Read and write the data it keeps with the document (a filter's `documentData`).
|
||||
#[serde(rename = "state")]
|
||||
State,
|
||||
/// Change how the objects its live effect is applied to are drawn.
|
||||
#[serde(rename = "effect.render")]
|
||||
EffectRender,
|
||||
/// Write the document as a file in its own format (an exporter).
|
||||
#[serde(rename = "export.write")]
|
||||
ExportWrite,
|
||||
/// Report how far a run has got, see that the user asked it to stop and write log lines: the
|
||||
/// host imports of API level 2 ([`crate::host::IMPORTS`]).
|
||||
#[serde(rename = "progress")]
|
||||
Progress,
|
||||
}
|
||||
|
||||
/// Every permission this host grants, in the order prompts list them.
|
||||
pub const ALL: [Permission; 6] =
|
||||
[Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite, Permission::Progress];
|
||||
|
||||
impl Permission {
|
||||
/// Its name in a manifest and in `plugin.install`'s `grant`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Permission::DocRead => "doc.read",
|
||||
Permission::DocWrite => "doc.write",
|
||||
Permission::State => "state",
|
||||
Permission::EffectRender => "effect.render",
|
||||
Permission::ExportWrite => "export.write",
|
||||
Permission::Progress => "progress",
|
||||
}
|
||||
}
|
||||
|
||||
/// The permission named `name`.
|
||||
pub fn parse(name: &str) -> Option<Permission> {
|
||||
ALL.into_iter().find(|p| p.name() == name)
|
||||
}
|
||||
|
||||
/// What it lets a plug-in do, in plain words (English; the app shows its own translation).
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
Permission::DocRead => "Read the objects it is given",
|
||||
Permission::DocWrite => "Change, add and delete objects in the document",
|
||||
Permission::State => "Keep data of its own with the document",
|
||||
Permission::EffectRender => "Change how the objects its effect is applied to are drawn",
|
||||
Permission::ExportWrite => "Write the document as a file in its own format",
|
||||
Permission::Progress => "Show its progress, see Cancel and write log lines",
|
||||
}
|
||||
}
|
||||
|
||||
/// May a plug-in of `kind` list it: one its kind implies, or `progress`, which any kind may
|
||||
/// list.
|
||||
pub fn applies(self, kind: Kind) -> bool {
|
||||
self == Permission::Progress || Permission::implied(kind).contains(&self)
|
||||
}
|
||||
|
||||
/// The permissions a manifest of `kind` without `permissions` gets. A manifest may also list
|
||||
/// `progress` ([`Permission::applies`]).
|
||||
pub fn implied(kind: Kind) -> &'static [Permission] {
|
||||
match kind {
|
||||
Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State],
|
||||
Kind::Effect => &[Permission::DocRead, Permission::EffectRender],
|
||||
Kind::Exporter => &[Permission::DocRead, Permission::ExportWrite],
|
||||
}
|
||||
}
|
||||
|
||||
/// The permissions a plug-in of `kind` can't work without, which its list must hold: a live
|
||||
/// effect and an exporter are always handed objects, and only draw or write. A filter may
|
||||
/// leave any of its permissions out.
|
||||
fn required(kind: Kind) -> &'static [Permission] {
|
||||
match kind {
|
||||
Kind::Filter => &[],
|
||||
Kind::Effect | Kind::Exporter => Permission::implied(kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the `permissions` a manifest of `kind` lists → them, in the order [`ALL`] has. A name
|
||||
/// that is not a permission, a reserved one, one that doesn't apply to the kind, one listed twice
|
||||
/// and a list without what the kind needs are errors that name it.
|
||||
pub(crate) fn validate(kind: Kind, names: &[String]) -> Result<Vec<Permission>> {
|
||||
if names.len() > MAX_PERMISSIONS {
|
||||
return Err(Error::Manifest(format!("at most {MAX_PERMISSIONS} `permissions`")));
|
||||
}
|
||||
let mut out: Vec<Permission> = Vec::with_capacity(names.len());
|
||||
for name in names {
|
||||
let shown = display_text(name, MAX_SHOWN_FIELD);
|
||||
let Some(p) = Permission::parse(name) else {
|
||||
if RESERVED.contains(&name.as_str()) {
|
||||
return Err(Error::Manifest(format!("permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it")));
|
||||
}
|
||||
return Err(Error::Manifest(format!("unknown permission `{shown}`")));
|
||||
};
|
||||
if !p.applies(kind) {
|
||||
return Err(Error::Manifest(format!("permission `{shown}` doesn't apply to a plug-in of kind `{}`", kind.name())));
|
||||
}
|
||||
if out.contains(&p) {
|
||||
return Err(Error::Manifest(format!("permission `{shown}` is listed twice")));
|
||||
}
|
||||
out.push(p);
|
||||
}
|
||||
if let Some(missing) = Permission::required(kind).iter().find(|p| !out.contains(p)) {
|
||||
return Err(Error::Manifest(format!("a plug-in of kind `{}` needs the permission `{}`", kind.name(), missing.name())));
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The names of `permissions`, as a manifest and `grant` list them.
|
||||
pub fn names(permissions: &[Permission]) -> Vec<&'static str> {
|
||||
permissions.iter().map(|p| p.name()).collect()
|
||||
}
|
||||
|
||||
/// Is `c` a character that changes the direction text is laid out in, or a line or paragraph
|
||||
/// separator, which a prompt must not let a manifest string use to disguise itself?
|
||||
fn is_bidi_or_separator(c: char) -> bool {
|
||||
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{2028}' | '\u{2029}')
|
||||
}
|
||||
|
||||
/// `s` as a prompt shows a string from a manifest: control, bidirectional formatting and line
|
||||
/// separator characters written as `\u{202E}` escapes, and at most `max` characters, the last of
|
||||
/// them `…` when it was cut.
|
||||
pub fn display_text(s: &str, max: usize) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut out = String::new();
|
||||
// Characters written so far, and the byte length of the longest prefix of whole pieces
|
||||
// shorter than `max` characters, where the ellipsis goes when the text is cut.
|
||||
let mut count = 0;
|
||||
let mut cut = 0;
|
||||
for c in s.chars() {
|
||||
let start = out.len();
|
||||
let n = if c.is_control() || is_bidi_or_separator(c) {
|
||||
let _ = write!(out, "\\u{{{:04X}}}", c as u32);
|
||||
out.len() - start
|
||||
} else {
|
||||
out.push(c);
|
||||
1
|
||||
};
|
||||
if count + n > max {
|
||||
out.truncate(cut);
|
||||
out.push('…');
|
||||
return out;
|
||||
}
|
||||
count += n;
|
||||
if count < max {
|
||||
cut = out.len();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The SHA-256 of `bytes`, in lower-case hexadecimal.
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// What the user approved for a plug-in: its id, the SHA-256 of the module they approved
|
||||
/// ([`sha256_hex`]) and the permissions it was granted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Grant {
|
||||
pub id: String,
|
||||
pub hash: String,
|
||||
pub permissions: Vec<Permission>,
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
/// Does it grant every one of `wanted`?
|
||||
pub fn covers(&self, wanted: &[Permission]) -> bool {
|
||||
wanted.iter().all(|w| self.permissions.contains(w))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_round_trip_and_serialize_as_names() {
|
||||
for p in ALL {
|
||||
assert_eq!(Permission::parse(p.name()), Some(p));
|
||||
assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.name()));
|
||||
}
|
||||
assert_eq!(Permission::parse("document.read"), None);
|
||||
assert!(RESERVED.iter().all(|r| Permission::parse(r).is_none()), "reserved names are not granted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_are_checked_against_the_kind() {
|
||||
let list = |names: &[&str]| names.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(validate(Kind::Filter, &list(&["doc.write", "doc.read"])).unwrap(), [Permission::DocRead, Permission::DocWrite]);
|
||||
assert_eq!(validate(Kind::Filter, &[]).unwrap(), []);
|
||||
assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok());
|
||||
assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok());
|
||||
// Any kind may list `progress`, which no kind implies.
|
||||
assert_eq!(validate(Kind::Filter, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]);
|
||||
assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render", "progress"])).is_ok());
|
||||
assert!(validate(Kind::Exporter, &list(&["progress", "export.write", "doc.read"])).is_ok());
|
||||
assert!([Kind::Filter, Kind::Effect, Kind::Exporter].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress)));
|
||||
for (kind, names, says) in [
|
||||
(Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"),
|
||||
(Kind::Filter, &["ui.panel"], "`ui.panel` is reserved"),
|
||||
(Kind::Filter, &["progress", "progress"], "`progress` is listed twice"),
|
||||
(Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"),
|
||||
(Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"),
|
||||
(Kind::Effect, &["doc.read"], "needs the permission `effect.render`"),
|
||||
(Kind::Exporter, &["export.write"], "needs the permission `doc.read`"),
|
||||
] {
|
||||
let e = validate(kind, &list(names)).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "{names:?}: {e}");
|
||||
}
|
||||
let many = list(&["doc.read"; MAX_PERMISSIONS + 1]);
|
||||
assert!(validate(Kind::Filter, &many).unwrap_err().to_string().contains("at most"));
|
||||
}
|
||||
|
||||
/// Control, bidirectional override and separator characters show as escapes, so a name can't
|
||||
/// reverse or hide the text after it; long text is cut with an ellipsis.
|
||||
#[test]
|
||||
fn prompt_text_escapes_hidden_characters_and_is_capped() {
|
||||
assert_eq!(display_text("Tidy\u{202E}fdp.exe", 64), "Tidy\\u{202E}fdp.exe");
|
||||
assert_eq!(display_text("a\u{0007}b\nc\u{2066}d\u{200F}", 64), "a\\u{0007}b\\u{000A}c\\u{2066}d\\u{200F}");
|
||||
assert_eq!(display_text("plain", 64), "plain");
|
||||
assert_eq!(display_text("abcdef", 6), "abcdef");
|
||||
assert_eq!(display_text("abcdefg", 6), "abcde…");
|
||||
assert_eq!(display_text(&"x".repeat(500), MAX_SHOWN_NAME).chars().count(), MAX_SHOWN_NAME);
|
||||
assert_eq!(display_text("ab\u{202E}", 6), "ab…", "an escape that doesn't fit is cut whole");
|
||||
assert_eq!(display_text("ab\u{0007}", 10), "ab\\u{0007}", "an escape that ends exactly at the cap is kept");
|
||||
let long = display_text(&"\u{202E}y".repeat(500_000), MAX_SHOWN_NAME);
|
||||
assert_eq!(long.chars().count(), MAX_SHOWN_NAME);
|
||||
assert!(long.starts_with("\\u{202E}y\\u{202E}y") && long.ends_with('…'), "{long}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_are_sha256_in_hex() {
|
||||
assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_covers_its_permissions_and_no_more() {
|
||||
let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
|
||||
assert!(g.covers(&[Permission::DocRead]));
|
||||
assert!(g.covers(&[]));
|
||||
assert!(!g.covers(&[Permission::State]));
|
||||
assert!(!g.covers(&[Permission::DocRead, Permission::State]));
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,41 @@ use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::Error;
|
||||
use crate::permissions::Grant;
|
||||
use crate::{Limits, Plugin, Result};
|
||||
|
||||
static PLUGINS: RwLock<BTreeMap<String, Arc<Plugin>>> = RwLock::new(BTreeMap::new());
|
||||
static REVISION: AtomicU64 = AtomicU64::new(0);
|
||||
/// What the user approved, by plug-in id.
|
||||
static GRANTS: RwLock<BTreeMap<String, Grant>> = RwLock::new(BTreeMap::new());
|
||||
|
||||
/// Records what the user approved for a plug-in, replacing an earlier grant for its id.
|
||||
pub fn grant(g: Grant) {
|
||||
GRANTS.write().unwrap_or_else(|e| e.into_inner()).insert(g.id.clone(), g);
|
||||
}
|
||||
|
||||
/// What the user approved for plug-in `id`.
|
||||
pub fn granted(id: &str) -> Option<Grant> {
|
||||
GRANTS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned()
|
||||
}
|
||||
|
||||
/// Every grant, sorted by id.
|
||||
pub fn grants() -> Vec<Grant> {
|
||||
GRANTS.read().unwrap_or_else(|e| e.into_inner()).values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Forgets what the user approved for `id`; false if nothing was.
|
||||
pub fn revoke(id: &str) -> bool {
|
||||
GRANTS.write().unwrap_or_else(|e| e.into_inner()).remove(id).is_some()
|
||||
}
|
||||
|
||||
/// May `plugin` be installed without asking the user: does it list no permissions, or did the user
|
||||
/// already grant its id every permission it lists? A module with other bytes but no new permission
|
||||
/// needs no new approval.
|
||||
pub fn approved(plugin: &Plugin) -> bool {
|
||||
let m = plugin.manifest();
|
||||
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions()))
|
||||
}
|
||||
|
||||
/// Installs `plugin`, replacing one with the same id.
|
||||
pub fn install(mut plugin: Plugin) -> Arc<Plugin> {
|
||||
@@ -93,16 +124,21 @@ pub struct FolderReport {
|
||||
|
||||
/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is
|
||||
/// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]),
|
||||
/// which installs only once the user agrees.
|
||||
/// which installs only once the user agrees, and one that lists permissions the user has not
|
||||
/// granted its id ([`approved`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
|
||||
load_folder_where(dir, |_| Ok(()))
|
||||
load_folder_where(dir, |_| Ok(()), approved)
|
||||
}
|
||||
|
||||
/// [`load_folder`], loading only the files `allow` accepts; the others are reported as failed
|
||||
/// with its reason.
|
||||
/// [`load_folder`], loading only the files `allow` accepts, and of the plug-ins that list
|
||||
/// permissions only those `approve` accepts; the others are reported as failed with the reason.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>) -> Result<FolderReport> {
|
||||
pub fn load_folder_where(
|
||||
dir: &std::path::Path,
|
||||
allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>,
|
||||
approve: impl Fn(&Plugin) -> bool,
|
||||
) -> Result<FolderReport> {
|
||||
let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?;
|
||||
let mut files: Vec<std::path::PathBuf> =
|
||||
rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect();
|
||||
@@ -119,6 +155,13 @@ pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path)
|
||||
Ok(p) if p.manifest().budget == crate::Budget::Heavy => {
|
||||
report.failed.push((f.display().to_string(), "asks for a heavy budget, which the user allows when installing it on its own".into()));
|
||||
}
|
||||
Ok(p) if p.manifest().asks_permissions() && !approve(&p) => {
|
||||
let asked = crate::permissions::names(&p.manifest().permissions()).join(", ");
|
||||
report.failed.push((
|
||||
f.display().to_string(),
|
||||
format!("needs approval: it asks for {asked}, which the user grants when installing it on its own"),
|
||||
));
|
||||
}
|
||||
Ok(p) => report.loaded.push(install(p).id().to_string()),
|
||||
Err(e) => report.failed.push((f.display().to_string(), e.to_string())),
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! The wasmi host: loading, instantiating and calling a plug-in under resource limits.
|
||||
|
||||
use wasmi::{
|
||||
Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc,
|
||||
TypedResumableCall, WasmParams, WasmResults,
|
||||
Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Memory, Module, Store, StoreLimitsBuilder, TypedFunc, TypedResumableCall, WasmParams,
|
||||
WasmResults,
|
||||
};
|
||||
|
||||
use crate::host::{self, Deadline, Host, Report, Run};
|
||||
use crate::manifest::{MAX_MANIFEST_BYTES, Manifest};
|
||||
use crate::{Error, Result};
|
||||
use crate::permissions::Permission;
|
||||
use crate::{Error, Kind, Result};
|
||||
|
||||
/// The plug-in ABI version this host implements (`vc_abi_version` must return it).
|
||||
pub const ABI_VERSION: i32 = 1;
|
||||
@@ -85,6 +87,7 @@ pub struct Plugin {
|
||||
module: Module,
|
||||
limits: Limits,
|
||||
size: usize,
|
||||
hash: String,
|
||||
source: Option<String>,
|
||||
serial: u64,
|
||||
}
|
||||
@@ -95,33 +98,10 @@ impl std::fmt::Debug for Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// When a run must stop (native: a wall-clock deadline).
|
||||
struct Deadline {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Deadline {
|
||||
fn new(limits: &Limits) -> Self {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = limits;
|
||||
Self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant::now() + std::time::Duration::from_millis(limits.wall_time_ms),
|
||||
}
|
||||
}
|
||||
fn check(&self) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::time::Instant::now() >= self.at {
|
||||
return Err(Error::Limit("time budget".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One live instance: a store with its own memory limit, and the module's exports.
|
||||
/// One live instance: a store with its own memory limit and the run's state, and the module's
|
||||
/// exports.
|
||||
struct Live {
|
||||
store: Store<StoreLimits>,
|
||||
store: Store<Host>,
|
||||
instance: Instance,
|
||||
memory: Memory,
|
||||
}
|
||||
@@ -151,7 +131,9 @@ fn wasm_err(e: wasmi::Error) -> Error {
|
||||
type RunArgs = (i32, i32, i32, i32);
|
||||
|
||||
impl Plugin {
|
||||
/// Validates and loads a module: checks the ABI version, the exports and reads the manifest.
|
||||
/// Validates and loads a module: checks the ABI version, the exports and reads the manifest, and
|
||||
/// checks that each host import it asks for is one the host offers ([`crate::host::IMPORTS`])
|
||||
/// and its manifest allows.
|
||||
pub fn load(bytes: &[u8], limits: Limits) -> Result<Plugin> {
|
||||
if bytes.len() > limits.max_module_bytes {
|
||||
return Err(Error::Module(format!("module is {} bytes (limit {})", bytes.len(), limits.max_module_bytes)));
|
||||
@@ -167,16 +149,12 @@ impl Plugin {
|
||||
.set_max_recursion_depth(limits.max_recursion_depth.max(16));
|
||||
let engine = Engine::new(&config);
|
||||
let module = Module::new(&engine, bytes).map_err(|e| Error::Module(e.to_string()))?;
|
||||
if let Some(imp) = module.imports().next() {
|
||||
return Err(Error::Abi(format!(
|
||||
"the module imports `{}.{}`, but plug-ins get no host imports (no WASI, files or network)",
|
||||
imp.module(),
|
||||
imp.name()
|
||||
)));
|
||||
}
|
||||
let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), source: None, serial: 0 };
|
||||
let deadline = Deadline::new(&p.limits);
|
||||
let mut live = p.instantiate()?;
|
||||
let imports = host::check_imports(&module)?;
|
||||
let hash = crate::permissions::sha256_hex(bytes);
|
||||
let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), hash, source: None, serial: 0 };
|
||||
let deadline = Deadline::new(p.limits.wall_time_ms);
|
||||
// The manifest isn't read yet: every host import is linked, and what it reports is dropped.
|
||||
let mut live = p.instantiate(Run::new("", false, false, deadline), true, &mut Report::default())?;
|
||||
let version: TypedFunc<(), i32> = live.func("vc_abi_version")?;
|
||||
let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?;
|
||||
if v != ABI_VERSION {
|
||||
@@ -189,6 +167,7 @@ impl Plugin {
|
||||
e => e,
|
||||
})?;
|
||||
p.manifest = Manifest::parse(&text)?;
|
||||
host::check_granted(&p.manifest, &imports)?;
|
||||
// A heavy filter's runs get the larger budget the host allows for one, which the host
|
||||
// lets the user allow or refuse before installing it.
|
||||
if p.manifest.budget == crate::Budget::Heavy {
|
||||
@@ -214,6 +193,10 @@ impl Plugin {
|
||||
pub fn size(&self) -> usize {
|
||||
self.size
|
||||
}
|
||||
/// The module's SHA-256, in lower-case hexadecimal.
|
||||
pub fn hash(&self) -> &str {
|
||||
&self.hash
|
||||
}
|
||||
/// Where the module was loaded from, if it came from a file.
|
||||
pub fn source(&self) -> Option<&str> {
|
||||
self.source.as_deref()
|
||||
@@ -231,16 +214,24 @@ impl Plugin {
|
||||
self.serial = serial;
|
||||
}
|
||||
|
||||
fn instantiate(&self) -> Result<Live> {
|
||||
/// A new instance for `run`, with the host imports linked when `granted`. When the module's
|
||||
/// start function fails, what it reported through the host imports goes to `report`.
|
||||
fn instantiate(&self, run: Run, granted: bool, report: &mut Report) -> Result<Live> {
|
||||
let limits =
|
||||
StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
|
||||
let mut store = Store::new(&self.engine, limits);
|
||||
store.limiter(|l| l);
|
||||
let mut store = Store::new(&self.engine, Host { limits, run });
|
||||
store.limiter(|h| &mut h.limits);
|
||||
// The start function (if any) runs on one slice of fuel: it runs before `call` can check
|
||||
// the deadline or a cancel between slices, so a heavy budget would let it run for minutes.
|
||||
store.set_fuel(self.limits.fuel_base.min(FUEL_SLICE)).map_err(wasm_err)?;
|
||||
let linker = Linker::<StoreLimits>::new(&self.engine);
|
||||
let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?;
|
||||
let linker = host::linker(&self.engine, granted);
|
||||
let instance = match linker.instantiate_and_start(&mut store, &self.module) {
|
||||
Ok(instance) => instance,
|
||||
Err(e) => {
|
||||
*report = std::mem::take(&mut store.data_mut().run.report);
|
||||
return Err(wasm_err(e));
|
||||
}
|
||||
};
|
||||
let memory =
|
||||
instance.get_memory(&store, "memory").ok_or_else(|| Error::Abi("the module must export its linear memory as `memory`".into()))?;
|
||||
Ok(Live { store, instance, memory })
|
||||
@@ -248,6 +239,12 @@ impl Plugin {
|
||||
|
||||
/// Calls `f` with at most `fuel` instructions, in slices, checking the deadline and the
|
||||
/// caller's cancellation ([`vectorcraft_doc::cancel`]) between them.
|
||||
///
|
||||
/// wasmi pauses before an instruction that the store's fuel can't pay for, and charges nothing
|
||||
/// for it. It charges a `memory.fill` or `memory.copy` for its whole length before writing, so
|
||||
/// up to that cost can be left in the store at a pause. Each slice tops the store up to the
|
||||
/// slice size, keeping that fuel, and a run ends with the instruction budget only when the
|
||||
/// store's fuel and the rest of the budget together can't pay for the next instruction.
|
||||
fn call<P: WasmParams, R: WasmResults>(&self, live: &mut Live, f: &TypedFunc<P, R>, params: P, fuel: u64, deadline: &Deadline) -> Result<R> {
|
||||
let mut left = fuel;
|
||||
let give = left.min(FUEL_SLICE);
|
||||
@@ -260,14 +257,16 @@ impl Plugin {
|
||||
TypedResumableCall::HostTrap(_) => return Err(Error::Trap("unexpected host trap".into())),
|
||||
TypedResumableCall::OutOfFuel(inv) => {
|
||||
let need = inv.required_fuel();
|
||||
if left == 0 || need > left {
|
||||
let held = live.store.get_fuel().map_err(wasm_err)?;
|
||||
let total = held.saturating_add(left);
|
||||
if need > total {
|
||||
return Err(Error::Limit("instruction budget".into()));
|
||||
}
|
||||
deadline.check()?;
|
||||
vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?;
|
||||
let give = left.min(FUEL_SLICE.max(need));
|
||||
left -= give;
|
||||
live.store.set_fuel(give).map_err(wasm_err)?;
|
||||
let fill = total.min(FUEL_SLICE.max(need)).max(held);
|
||||
left -= fill - held;
|
||||
live.store.set_fuel(fill).map_err(wasm_err)?;
|
||||
call = inv.resume(&mut live.store).map_err(wasm_err)?;
|
||||
}
|
||||
}
|
||||
@@ -282,21 +281,39 @@ impl Plugin {
|
||||
|
||||
/// [`Plugin::run`] with other time and instruction budgets (live effects get a shorter one).
|
||||
pub fn run_limited(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result<Vec<u8>> {
|
||||
// wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must
|
||||
// still not take the document down (native builds; wasm aborts on panic anyway).
|
||||
let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits)));
|
||||
run.map_err(|_| Error::Trap("the runtime panicked".into()))?
|
||||
self.run_reporting(input, params, limits).0
|
||||
}
|
||||
|
||||
fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result<Vec<u8>> {
|
||||
/// [`Plugin::run_limited`], and what the run reported through the host imports: its last
|
||||
/// progress report and its log lines, which also go to the app's log under its id. Inside a
|
||||
/// [`vectorcraft_doc::cancel::scope`] its progress goes to the scope's sink (a live effect's
|
||||
/// doesn't) and `vc_cancelled` sees the scope's Cancel.
|
||||
pub fn run_reporting(&self, input: &[u8], params: &[u8], limits: &Limits) -> (Result<Vec<u8>>, Report) {
|
||||
let mut report = Report::default();
|
||||
// wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must
|
||||
// still not take the document down (native builds; wasm aborts on panic anyway).
|
||||
let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits, &mut report)));
|
||||
(run.map_err(|_| Error::Trap("the runtime panicked".into())).and_then(|r| r), report)
|
||||
}
|
||||
|
||||
fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report) -> Result<Vec<u8>> {
|
||||
let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2);
|
||||
total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?;
|
||||
let deadline = Deadline::new(limits);
|
||||
let mut live = self.instantiate()?;
|
||||
let deadline = Deadline::new(limits.wall_time_ms);
|
||||
let m = &self.manifest;
|
||||
let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline);
|
||||
let mut live = self.instantiate(run, m.permissions().contains(&Permission::Progress), report)?;
|
||||
let out = self.run_live(&mut live, input, params, limits, &deadline);
|
||||
*report = std::mem::take(&mut live.store.data_mut().run.report);
|
||||
out
|
||||
}
|
||||
|
||||
/// Hands `input` and `params` to the instance `live` and runs `vc_run` → the output JSON bytes.
|
||||
fn run_live(&self, live: &mut Live, input: &[u8], params: &[u8], limits: &Limits, deadline: &Deadline) -> Result<Vec<u8>> {
|
||||
let alloc: TypedFunc<i32, i32> = live.func("vc_alloc")?;
|
||||
let run: TypedFunc<RunArgs, i64> = live.func("vc_run")?;
|
||||
let place = |live: &mut Live, bytes: &[u8]| -> Result<usize> {
|
||||
let ptr = self.call(live, &alloc, bytes.len().max(1) as i32, limits.fuel_base, &deadline)? as u32 as usize;
|
||||
let ptr = self.call(live, &alloc, bytes.len().max(1) as i32, limits.fuel_base, deadline)? as u32 as usize;
|
||||
if ptr == 0 {
|
||||
return Err(Error::Failed("vc_alloc returned 0 (out of memory)".into()));
|
||||
}
|
||||
@@ -305,10 +322,14 @@ impl Plugin {
|
||||
live.memory.data_mut(&mut live.store).get_mut(ptr..end).ok_or_else(outside)?.copy_from_slice(bytes);
|
||||
Ok(ptr)
|
||||
};
|
||||
let iptr = place(&mut live, input)?;
|
||||
let pptr = place(&mut live, params)?;
|
||||
let iptr = place(live, input)?;
|
||||
let pptr = place(live, params)?;
|
||||
let fuel = limits.fuel_base.saturating_add((input.len() as u64).saturating_mul(limits.fuel_per_byte));
|
||||
let packed = self.call(&mut live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, &deadline)?;
|
||||
let packed = self.call(live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, deadline)?;
|
||||
// A cancelled run, or one past its time budget, changes nothing, also when the plug-in saw
|
||||
// `vc_cancelled` and returned.
|
||||
vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?;
|
||||
deadline.check()?;
|
||||
if packed < 0 {
|
||||
return Err(Error::Failed(format!("vc_run returned error code {packed}")));
|
||||
}
|
||||
|
||||
@@ -42,11 +42,51 @@ pub fn returning(manifest: &str, output: &str) -> String {
|
||||
/// A plug-in whose `vc_run` runs `first` (instructions, with the locals it declares first), then
|
||||
/// returns `output`.
|
||||
fn then_returning(manifest: &str, first: &str, output: &str) -> String {
|
||||
with_imports(manifest, "", first, output)
|
||||
}
|
||||
|
||||
/// A plug-in with the module fields `imports` before the others, whose `vc_run` runs `first`, then
|
||||
/// returns `output`.
|
||||
pub fn with_imports(manifest: &str, imports: &str, first: &str, output: &str) -> String {
|
||||
let run = format!("{first} (i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len());
|
||||
let extra = format!(r#"(data (i32.const {OUTPUT_AT}) "{}")"#, string(output));
|
||||
let extra = format!(r#"{imports} (data (i32.const {OUTPUT_AT}) "{}")"#, string(output));
|
||||
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
|
||||
}
|
||||
|
||||
/// The host imports of plug-in API level 2 ([`crate::host::IMPORTS`]), as `$vc_progress`,
|
||||
/// `$vc_cancelled` and `$vc_log`.
|
||||
pub const HOST_IMPORTS: &str = r#"(import "env" "vc_progress" (func $vc_progress (param i64 i64)))
|
||||
(import "env" "vc_cancelled" (func $vc_cancelled (result i32)))
|
||||
(import "env" "vc_log" (func $vc_log (param i32 i32 i32) (result i32)))"#;
|
||||
|
||||
/// A plug-in of API level 2 (its manifest gives `api` 2 and lists `progress`) that imports the
|
||||
/// host functions ([`HOST_IMPORTS`]) and whose `vc_run` runs `first`, which may call them, then
|
||||
/// returns `output`.
|
||||
pub fn reporting(manifest: &str, first: &str, output: &str) -> String {
|
||||
with_imports(manifest, HOST_IMPORTS, first, output)
|
||||
}
|
||||
|
||||
/// A plug-in of API level 2 that counts `steps` down, reporting each step's progress and checking
|
||||
/// `vc_cancelled` before it: when that says the run was cancelled it returns `stopped`, else
|
||||
/// `output` once the steps are done.
|
||||
pub fn cooperating(manifest: &str, steps: u64, stopped: &str, output: &str) -> String {
|
||||
let first = format!(
|
||||
r#"(local $n i64) (local.set $n (i64.const {steps}))
|
||||
(block $done (loop $step
|
||||
(br_if $done (i64.le_s (local.get $n) (i64.const 0)))
|
||||
(call $vc_progress (i64.sub (i64.const {steps}) (local.get $n)) (i64.const {steps}))
|
||||
(if (call $vc_cancelled) (then (return (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const {STOPPED_AT})))))
|
||||
(local.set $n (i64.sub (local.get $n) (i64.const 1)))
|
||||
(br $step)))"#,
|
||||
len = stopped.len(),
|
||||
);
|
||||
let imports = format!(r#"{HOST_IMPORTS} (data (i32.const {STOPPED_AT}) "{}")"#, string(stopped));
|
||||
with_imports(manifest, &imports, &first, output)
|
||||
}
|
||||
|
||||
/// Where [`cooperating`] keeps what it returns when it is cancelled.
|
||||
const STOPPED_AT: usize = 0x8000;
|
||||
|
||||
/// A plug-in that counts `steps` down before it returns `output`. Each step costs a few
|
||||
/// instructions, so a test picks work that a light budget can't finish and a heavy one can, or
|
||||
/// that runs until it is cancelled.
|
||||
|
||||
+195
-1
@@ -6,7 +6,7 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, heavy_terms, registry, wat as tpl};
|
||||
use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, Report, heavy_terms, registry, wat as tpl};
|
||||
|
||||
/// The example Desaturate plug-in, built from `crates/plugins/example`.
|
||||
const DESATURATE: &[u8] = include_bytes!("fixtures/desaturate.wasm");
|
||||
@@ -155,6 +155,45 @@ fn budgets_stop_runaway_plugins() {
|
||||
assert_eq!(e, Error::Limit("instruction budget".into()));
|
||||
}
|
||||
|
||||
/// A filter that runs `op`, an 8 MiB bulk memory instruction costing about 131,000 instructions,
|
||||
/// 160 times (about 21 million instructions in all), or forever when `endless`.
|
||||
fn bulk_module(op: &str, endless: bool) -> Vec<u8> {
|
||||
let bulk = match op {
|
||||
"fill" => "(memory.fill (i32.const 1024) (i32.const 7) (i32.const 8388608))",
|
||||
"copy" => "(memory.copy (i32.const 1024) (i32.const 2048) (i32.const 8387584))",
|
||||
_ => panic!("no bulk operation `{op}`"),
|
||||
};
|
||||
let run = if endless {
|
||||
format!("(loop $work {bulk} (br $work)) {}", tpl::ECHO)
|
||||
} else {
|
||||
format!(
|
||||
"(local $n i32) (local.set $n (i32.const 160)) \
|
||||
(loop $work {bulk} (local.set $n (i32.sub (local.get $n) (i32.const 1))) (br_if $work (local.get $n))) {}",
|
||||
tpl::ECHO
|
||||
)
|
||||
};
|
||||
build(&tpl::module(MANIFEST, &run, "", 130))
|
||||
}
|
||||
|
||||
fn bulk_run(op: &str, endless: bool, fuel_base: u64) -> vectorcraft_plugins::Result<Vec<u8>> {
|
||||
let p = Plugin::load(&bulk_module(op, endless), Limits { fuel_base, fuel_per_byte: 0, ..Limits::default() })?;
|
||||
p.run(INPUT.as_bytes(), b"{}")
|
||||
}
|
||||
|
||||
/// A bulk memory instruction that the fuel left in a slice of the budget can't pay for pauses the
|
||||
/// run without using that fuel, and the run keeps it: 160 fills or copies that cross the first
|
||||
/// slice finish within 21 million instructions, though the pause left about 75,000 of them in the
|
||||
/// slice. With fewer instructions than the work takes, or with a loop that never ends, the run
|
||||
/// still ends with the instruction budget.
|
||||
#[test]
|
||||
fn a_bulk_memory_instruction_at_the_end_of_a_slice_keeps_the_run_s_fuel() {
|
||||
for op in ["fill", "copy"] {
|
||||
assert_eq!(bulk_run(op, false, 21_000_000), Ok(INPUT.as_bytes().to_vec()), "{op} within its budget");
|
||||
assert_eq!(bulk_run(op, false, 15_000_000), Err(Error::Limit("instruction budget".into())), "{op} over its budget");
|
||||
assert_eq!(bulk_run(op, true, 21_000_000), Err(Error::Limit("instruction budget".into())), "endless {op}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_clock_budget_stops_runaway_plugins() {
|
||||
let spin = ok_module("(loop $l (br $l)) (i64.const 0)");
|
||||
@@ -308,6 +347,161 @@ fn folder_loading_skips_bad_files() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A filter of plug-in API level 2 that may use the host imports.
|
||||
const LEVEL2: &str =
|
||||
r#"{"id":"test.level2","name":"Level 2","version":"1","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}"#;
|
||||
|
||||
/// The error of loading `bytes`, as text.
|
||||
fn load_err(bytes: &[u8]) -> String {
|
||||
Plugin::load(bytes, Limits::default()).expect_err("the module must be refused").to_string()
|
||||
}
|
||||
|
||||
/// A module may import only the host's three functions, with the host's types, and only when its
|
||||
/// manifest gives `api` 2 and lists `progress`; the error names the import it refuses.
|
||||
#[test]
|
||||
fn host_imports_are_checked_when_a_module_is_loaded() {
|
||||
let with = |manifest: &str, import: &str| build(&tpl::with_imports(manifest, import, "", INPUT));
|
||||
let unknown = with(LEVEL2, r#"(import "env" "vc_clock" (func (result i64)))"#);
|
||||
assert!(load_err(&unknown).contains("imports `env.vc_clock`, which the host doesn't offer"), "{}", load_err(&unknown));
|
||||
let elsewhere = with(LEVEL2, r#"(import "vc" "vc_log" (func (param i32 i32 i32) (result i32)))"#);
|
||||
assert!(load_err(&elsewhere).contains("imports `vc.vc_log`, which the host doesn't offer"), "{}", load_err(&elsewhere));
|
||||
let wrong_type = with(LEVEL2, r#"(import "env" "vc_progress" (func (param i32 i32)))"#);
|
||||
assert!(load_err(&wrong_type).contains("imports `env.vc_progress` with another type than the host's: it is (param i64 i64)"));
|
||||
let memory = with(LEVEL2, r#"(import "env" "vc_cancelled" (global i32))"#);
|
||||
assert!(load_err(&memory).contains("imports `env.vc_cancelled` with another type"), "{}", load_err(&memory));
|
||||
let progress = r#"(import "env" "vc_progress" (func (param i64 i64)))"#;
|
||||
let ungranted = r#"{"id":"test.ungranted","name":"U","kind":"filter","api":2,"permissions":["doc.read","doc.write"]}"#;
|
||||
let says = "imports `env.vc_progress`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `progress`";
|
||||
for manifest in [ungranted, r#"{"id":"test.implied","name":"U","kind":"filter","api":2}"#, MANIFEST] {
|
||||
let e = load_err(&with(manifest, progress));
|
||||
assert!(e.contains(says), "{manifest}: {e}");
|
||||
}
|
||||
let p = Plugin::load(&with(LEVEL2, progress), Limits::default()).unwrap();
|
||||
assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes());
|
||||
// A level-2 plug-in that imports nothing runs as a level-1 one does.
|
||||
let plain = Plugin::load(&build(&tpl::returning(LEVEL2, INPUT)), Limits::default()).unwrap();
|
||||
assert_eq!(plain.run_reporting(INPUT.as_bytes(), b"{}", plain.limits()), (Ok(INPUT.as_bytes().to_vec()), Report::default()));
|
||||
}
|
||||
|
||||
/// Progress is clamped and passed on to the caller's sink as a share; the run's last report is kept.
|
||||
#[test]
|
||||
fn progress_reports_are_clamped_and_reach_the_caller() {
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
let first = "(call $vc_progress (i64.const 50) (i64.const 10)) (call $vc_progress (i64.const -3) (i64.const 8)) (call $vc_progress (i64.const 3) (i64.const 8))";
|
||||
let p = Plugin::load(&build(&tpl::reporting(LEVEL2, first, INPUT)), Limits::default()).unwrap();
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = {
|
||||
let seen = seen.clone();
|
||||
Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string())))
|
||||
};
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
let (out, report) = vectorcraft_doc::cancel::scope(&token, Some(sink), || p.run_reporting(INPUT.as_bytes(), b"{}", p.limits()));
|
||||
assert_eq!(out.unwrap(), INPUT.as_bytes());
|
||||
assert_eq!(report.progress, Some((3, 8)));
|
||||
let step = vectorcraft_plugins::host::PROGRESS_STEP.to_string();
|
||||
assert_eq!(*seen.borrow(), [(10, 10, step.clone()), (0, 8, step.clone()), (3, 8, step)]);
|
||||
// A live effect's progress stays its own: it is drawn as part of something else.
|
||||
let effect = r#"{"id":"test.level2.effect","name":"E","kind":"effect","api":2,"permissions":["doc.read","effect.render","progress"]}"#;
|
||||
let e = Plugin::load(&build(&tpl::reporting(effect, first, INPUT)), Limits::default()).unwrap();
|
||||
let quiet = Rc::new(RefCell::new(0));
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = {
|
||||
let quiet = quiet.clone();
|
||||
Rc::new(move |_, _, _| *quiet.borrow_mut() += 1)
|
||||
};
|
||||
let (_, report) = vectorcraft_doc::cancel::scope(&token, Some(sink), || e.run_reporting(INPUT.as_bytes(), b"{}", e.limits()));
|
||||
assert_eq!((report.progress, *quiet.borrow()), (Some((3, 8)), 0));
|
||||
}
|
||||
|
||||
/// Cancel makes `vc_cancelled` return 1, and a plug-in that checks it stops at once; what it returns
|
||||
/// then is discarded, so the run is `Cancelled`; past the time budget the same holds. One that
|
||||
/// ignores it still ends at its instruction budget. Outside a cancelled scope it returns 0.
|
||||
#[test]
|
||||
fn cancel_reaches_a_cooperating_plugin_and_a_budget_ends_one_that_ignores_it() {
|
||||
let p =
|
||||
Plugin::load(&build(&tpl::cooperating(LEVEL2, 1 << 40, r#"{"stopped":1}"#, INPUT)), Limits { fuel_base: u64::MAX / 4, ..Limits::default() })
|
||||
.unwrap();
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
token.cancel();
|
||||
let (out, report) = vectorcraft_doc::cancel::scope(&token, None, || p.run_reporting(INPUT.as_bytes(), b"{}", p.limits()));
|
||||
assert_eq!(out, Err(Error::Cancelled), "what it returned after the Cancel is discarded");
|
||||
assert_eq!(report.progress, Some((0, 1 << 40)), "it saw the Cancel on its first step");
|
||||
// Not cancelled: it runs all its steps.
|
||||
let short = Plugin::load(&build(&tpl::cooperating(LEVEL2, 1000, r#"{"stopped":1}"#, INPUT)), Limits::default()).unwrap();
|
||||
let (out, report) = short.run_reporting(INPUT.as_bytes(), b"{}", short.limits());
|
||||
assert_eq!((out.unwrap(), report.progress), (INPUT.as_bytes().to_vec(), Some((999, 1000))));
|
||||
// Past its time budget `vc_cancelled` returns 1 too, and what it returns then is discarded.
|
||||
let late =
|
||||
Plugin::load(&build(&tpl::cooperating(LEVEL2, 1000, r#"{"stopped":1}"#, INPUT)), Limits { wall_time_ms: 0, ..Limits::default() }).unwrap();
|
||||
let (out, report) = late.run_reporting(INPUT.as_bytes(), b"{}", late.limits());
|
||||
assert_eq!((out, report.progress), (Err(Error::Limit("time budget".into())), Some((0, 1000))));
|
||||
// One that never checks ends at its budget, cancelled or not.
|
||||
let ignoring = build(&tpl::reporting(LEVEL2, "(loop $l (br $l))", INPUT));
|
||||
let p = Plugin::load(&ignoring, Limits { fuel_base: 1_000_000, ..Limits::default() }).unwrap();
|
||||
let r = vectorcraft_doc::cancel::scope(&token, None, || p.run(INPUT.as_bytes(), b"{}"));
|
||||
assert_eq!(r, Err(Error::Limit("instruction budget".into())));
|
||||
}
|
||||
|
||||
/// A log line longer than the cap is cut; lines past the run's budget are dropped, with one line
|
||||
/// that says so; text outside memory, text that isn't UTF-8 and an unknown level are errors the
|
||||
/// plug-in sees, never a panic.
|
||||
#[test]
|
||||
fn log_lines_are_capped_and_bad_text_is_an_error_the_plugin_sees() {
|
||||
use vectorcraft_plugins::host::{self, LOG_BAD_LEVEL, LOG_NOT_UTF8, LOG_OUTSIDE_MEMORY, MAX_LOG_LINE, MAX_LOG_LINES};
|
||||
// `$text` at 0x2000 holds "hi", then 0xff, then 2000 bytes of "x".
|
||||
let expect = |level: i32, ptr: &str, len: &str, want: i32| {
|
||||
format!("(if (i32.ne (call $vc_log (i32.const {level}) {ptr} {len}) (i32.const {want})) (then unreachable))")
|
||||
};
|
||||
let first = [
|
||||
expect(2, "(i32.const 0x2000)", "(i32.const 2)", 0),
|
||||
expect(1, "(i32.const 0x2003)", "(i32.const 2000)", 0),
|
||||
expect(2, "(i32.const -1)", "(i32.const 5)", LOG_OUTSIDE_MEMORY),
|
||||
expect(2, "(i32.const 0x2000)", "(i32.const -1)", LOG_OUTSIDE_MEMORY),
|
||||
expect(2, "(i32.mul (memory.size) (i32.const 65536))", "(i32.const 1)", LOG_OUTSIDE_MEMORY),
|
||||
expect(2, "(i32.const 0x2000)", "(i32.const 3)", LOG_NOT_UTF8),
|
||||
expect(9, "(i32.const 0x2000)", "(i32.const 2)", LOG_BAD_LEVEL),
|
||||
]
|
||||
.concat();
|
||||
let data = format!(r#"(data (i32.const 0x2000) "hi\ff{}")"#, "x".repeat(2000));
|
||||
let imports = format!("{} {data}", tpl::HOST_IMPORTS);
|
||||
let p = Plugin::load(&build(&tpl::with_imports(LEVEL2, &imports, &first, INPUT)), Limits::default()).unwrap();
|
||||
let (out, report) = p.run_reporting(INPUT.as_bytes(), b"{}", p.limits());
|
||||
assert_eq!(out.unwrap(), INPUT.as_bytes(), "every call returned what it should");
|
||||
assert_eq!(report.lines.len(), 2);
|
||||
assert_eq!(report.lines[0], vectorcraft_plugins::LogLine { level: vectorcraft_plugins::LogLevel::Info, text: "hi".into() });
|
||||
assert_eq!(report.lines[1].text, format!("{}…", "x".repeat(MAX_LOG_LINE - 2)), "cut at the cap, ellipsis included");
|
||||
// Past the budget: dropped, with one line that says so.
|
||||
let flood = format!(
|
||||
"(local $i i32) (loop $l (drop (call $vc_log (i32.const 2) (i32.const 0x2000) (i32.const 2))) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br_if $l (i32.lt_u (local.get $i) (i32.const {}))))",
|
||||
MAX_LOG_LINES * 3
|
||||
);
|
||||
let p = Plugin::load(&build(&tpl::with_imports(LEVEL2, &imports, &flood, INPUT)), Limits::default()).unwrap();
|
||||
let (out, report) = p.run_reporting(INPUT.as_bytes(), b"{}", p.limits());
|
||||
assert!(out.is_ok() && report.truncated);
|
||||
assert_eq!(report.lines.len(), MAX_LOG_LINES + 1);
|
||||
assert_eq!(report.lines.iter().filter(|l| l.text == host::LOG_TRUNCATED).count(), 1);
|
||||
}
|
||||
|
||||
/// What a module's start function reports through the host imports before it fails is kept in the
|
||||
/// run's report. This start function logs, reports progress and traps only when the run was
|
||||
/// cancelled, so the module loads.
|
||||
#[test]
|
||||
fn what_a_start_function_reports_before_it_fails_is_kept() {
|
||||
let start = r#"(data (i32.const 0x2000) "hi")
|
||||
(func $start (if (call $vc_cancelled) (then
|
||||
(drop (call $vc_log (i32.const 2) (i32.const 0x2000) (i32.const 2)))
|
||||
(call $vc_progress (i64.const 1) (i64.const 4))
|
||||
unreachable)))
|
||||
(start $start)"#;
|
||||
let imports = format!("{} {start}", tpl::HOST_IMPORTS);
|
||||
let p = Plugin::load(&build(&tpl::with_imports(LEVEL2, &imports, "", INPUT)), Limits::default()).unwrap();
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
token.cancel();
|
||||
let (out, report) = vectorcraft_doc::cancel::scope(&token, None, || p.run_reporting(INPUT.as_bytes(), b"{}", p.limits()));
|
||||
assert!(out.is_err(), "{out:?}");
|
||||
assert_eq!(report.lines, [vectorcraft_plugins::LogLine { level: vectorcraft_plugins::LogLevel::Info, text: "hi".into() }]);
|
||||
assert_eq!(report.progress, Some((1, 4)));
|
||||
}
|
||||
|
||||
/// A heavy filter installs once the user agrees, which loading a folder doesn't ask: it is skipped
|
||||
/// and reported.
|
||||
#[test]
|
||||
|
||||
@@ -31,6 +31,8 @@ pub(crate) use paint::{CROP_STROKES, crop_line};
|
||||
|
||||
impl Renderer {
|
||||
pub fn new() -> Self {
|
||||
// Envelopes map type from its glyphs here too, without an engine session.
|
||||
vectorcraft_text::register_envelope_ink();
|
||||
Self {
|
||||
texts: FrameCache::new(lru::TEXT_BUDGET, 1024),
|
||||
text_ids: PtrMap::default(),
|
||||
|
||||
@@ -320,3 +320,35 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() {
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "{what}: as a new renderer draws it");
|
||||
}
|
||||
}
|
||||
|
||||
/// Without an engine session, a renderer maps an envelope of type from its glyph outline bounds,
|
||||
/// and measures the type again when its font becomes available.
|
||||
#[test]
|
||||
fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() {
|
||||
let _adding = adding_fonts();
|
||||
const FAMILY: &str = "Mapped Serif 4";
|
||||
let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() };
|
||||
let t = TextObject::point(Point::new(10.0, 60.0), "Hamburg", style);
|
||||
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
|
||||
let envelope = Node::new(
|
||||
vectorcraft_doc::NodeId(1),
|
||||
NodeKind::Envelope {
|
||||
content: vec![Arc::new(Node::new(vectorcraft_doc::NodeId(2), NodeKind::Text(Box::new(t.clone()))))],
|
||||
kind: Box::new(kind),
|
||||
fidelity: 50.0,
|
||||
editing: false,
|
||||
options: Default::default(),
|
||||
frame: Box::new(Affine::IDENTITY),
|
||||
},
|
||||
);
|
||||
let ink = || t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap());
|
||||
let src = || vectorcraft_doc::live::EnvelopeMap::of(&envelope).unwrap().src();
|
||||
let off = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
let _ = Renderer::new();
|
||||
let before = ink();
|
||||
assert!(off(src(), before) < 1e-6, "{:?} vs {before:?}", src());
|
||||
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
|
||||
let after = ink();
|
||||
assert!(off(before, after) > 0.5, "the type draws in the font that arrived: {before:?} vs {after:?}");
|
||||
assert!(off(src(), after) < 1e-6, "{:?} vs {after:?}", src());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! A renderer made without an engine session makes envelopes measure type by its glyph outlines.
|
||||
//! One test in its own process, so nothing else has registered the measure before the renderer is
|
||||
//! made.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
/// The largest distance between the edges of `a` and `b`.
|
||||
fn off(a: Rect, b: Rect) -> f64 {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_renderer_measures_type_in_envelopes_by_its_glyphs() {
|
||||
let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() });
|
||||
let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
|
||||
let laid = n.geometric_bounds().unwrap();
|
||||
let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap());
|
||||
assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}");
|
||||
let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap();
|
||||
assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}");
|
||||
|
||||
let _ = vectorcraft_render::Renderer::new();
|
||||
let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap();
|
||||
assert!(off(after, ink) < 1e-6, "after a renderer is made, type is measured by its glyphs: {after:?} vs {ink:?}");
|
||||
}
|
||||
@@ -501,12 +501,17 @@ mod tests {
|
||||
assert_eq!(hyphen_points_with("language", &opts(&|o| o.capitalized = false)), vec![3]);
|
||||
}
|
||||
|
||||
/// Process-wide store used by `prefs.set` / restore.
|
||||
/// Process-wide store used by `prefs.set` / restore. Each change makes envelopes measure type
|
||||
/// again.
|
||||
#[test]
|
||||
fn set_hyphenation_exceptions_round_trips_the_source_string() {
|
||||
let inputs = crate::layout::envelope_ink_inputs();
|
||||
set_hyphenation_exceptions("typography, hap-pen");
|
||||
assert_eq!(hyphenation_exceptions(), "typography, hap-pen");
|
||||
let changed = crate::layout::envelope_ink_inputs();
|
||||
assert_ne!(changed, inputs, "the envelope inputs change with the exceptions");
|
||||
set_hyphenation_exceptions("");
|
||||
assert!(hyphenation_exceptions().is_empty());
|
||||
assert_ne!(crate::layout::envelope_ink_inputs(), changed, "and again when they are cleared");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ pub(crate) use driver::layout_faces_per_call;
|
||||
pub use driver::{decorations, fit_first_line, layout, layout_with, scaled};
|
||||
use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use driver::envelope_ink_inputs;
|
||||
pub use driver::{envelope_ink, register_envelope_ink};
|
||||
|
||||
const EPS: f64 = 1e-6;
|
||||
|
||||
/// Is glyph `g` a later glyph of the cluster of glyph `prev` before it (a combining mark the shaper
|
||||
|
||||
@@ -112,6 +112,62 @@ pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usi
|
||||
out
|
||||
}
|
||||
|
||||
/// The box of what `t` draws with text space mapped by `m`: its glyph outlines
|
||||
/// ([`TextLayout::ink_bounds_in`]) and its underline and strikethrough bars ([`decorations`]). With
|
||||
/// `projection` `(h, after)`, each point `m` maps is then drawn through `h` (a point `h` can't map
|
||||
/// stays where it is, as the renderer draws it) and mapped by `after`; the outlines and bars are
|
||||
/// flattened to within 1/1000 of their size, and images, color glyphs and inline graphics count by
|
||||
/// their boxes' corners. What an envelope maps type from ([`register_envelope_ink`]). `None` when
|
||||
/// nothing draws.
|
||||
pub fn envelope_ink(t: &TextObject, m: Affine, projection: Option<(vectorcraft_geom::Homography, Affine)>) -> Option<Rect> {
|
||||
let db = FontDb::global();
|
||||
let laid = layout(db, t);
|
||||
let bars = decorations(&laid, db, t).into_iter().map(|(_, bar)| bar);
|
||||
let union = |acc: Option<Rect>, b: Rect| Some(acc.map_or(b, |a| a.union(b)));
|
||||
let Some((h, after)) = projection else {
|
||||
return bars.map(|bar| (m * bar).bounding_box()).fold(laid.ink_bounds_in(m), union);
|
||||
};
|
||||
let draw = |p: Point| {
|
||||
let q = m * p;
|
||||
after * h.apply(q).unwrap_or(q)
|
||||
};
|
||||
let add = |acc: Option<Rect>, p: Point| union(acc, Rect::from_points(p, p));
|
||||
let projected = |path: &BezPath| {
|
||||
let size = path.bounding_box();
|
||||
let tolerance = (size.width().max(size.height()) / 1000.0).max(1e-6);
|
||||
let mut points = vec![];
|
||||
kurbo::flatten(path, tolerance, |el| {
|
||||
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
|
||||
points.push(draw(p));
|
||||
}
|
||||
});
|
||||
points.into_iter().fold(None, add)
|
||||
};
|
||||
let corners = |r: Rect| {
|
||||
[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(draw).into_iter().fold(None, add)
|
||||
};
|
||||
let glyphs = laid.glyphs.iter().chain(&laid.ruby).filter_map(|g| projected(&g.outline));
|
||||
let boxes = laid.glyphs.iter().flat_map(|g| {
|
||||
let image = g.placed_image().map(|(image, xf)| xf.transform_rect_bbox(image.rect()));
|
||||
let colour = g.colour.as_ref().and_then(|c| c.geometric_bounds()).map(|b| g.xf.transform_rect_bbox(b));
|
||||
[image, colour]
|
||||
});
|
||||
let boxes = boxes.chain(laid.inlines.iter().map(|i| Some(i.bounds))).flatten().filter_map(corners);
|
||||
glyphs.chain(bars.filter_map(|bar| projected(&bar))).chain(boxes).fold(None, union)
|
||||
}
|
||||
|
||||
/// Make envelopes map type from [`envelope_ink`], measured again when the fonts or the hyphenation
|
||||
/// exceptions change (the first registration stays, see `vectorcraft_doc::live::set_type_ink`).
|
||||
pub fn register_envelope_ink() {
|
||||
vectorcraft_doc::live::set_type_ink(envelope_ink, envelope_ink_inputs);
|
||||
}
|
||||
|
||||
/// A number that changes whenever what [`envelope_ink`] reads besides the type changes: the
|
||||
/// installed fonts and the hyphenation exceptions.
|
||||
pub(crate) fn envelope_ink_inputs() -> u64 {
|
||||
FontDb::global().generation().rotate_left(32) ^ crate::hyphen::exceptions_generation()
|
||||
}
|
||||
|
||||
/// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass).
|
||||
const SHRINK_PASSES: usize = 11;
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ pub use fontdb::{
|
||||
style_weight, system_font_dirs, user_font_dirs,
|
||||
};
|
||||
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
|
||||
pub use layout::{envelope_ink, register_envelope_ink};
|
||||
use std::sync::Arc;
|
||||
pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
|
||||
|
||||
@@ -440,6 +441,20 @@ impl TextLayout {
|
||||
let inlines = self.inlines.iter().map(|i| Some(i.bounds));
|
||||
glyphs.chain(ruby).chain(inlines).flatten().reduce(|a, b| a.union(b))
|
||||
}
|
||||
/// The box of [`Self::ink_bounds`]' ink with text space mapped by `m`: the glyph and ruby
|
||||
/// outlines are mapped point by point, so a rotated or sheared `m` gives the outlines' own
|
||||
/// bounds. Images, color glyphs and inline graphics count by their mapped boxes.
|
||||
pub fn ink_bounds_in(&self, m: Affine) -> Option<Rect> {
|
||||
let outlined = |g: &PositionedGlyph| (!g.outline.elements().is_empty()).then(|| (m * &g.outline).bounding_box());
|
||||
let glyphs = self.glyphs.iter().flat_map(|g| {
|
||||
let image = g.placed_image().map(|(image, xf)| (m * xf).transform_rect_bbox(image.rect()));
|
||||
let colour = g.colour.as_ref().and_then(|c| c.geometric_bounds()).map(|b| (m * g.xf).transform_rect_bbox(b));
|
||||
[outlined(g), image, colour]
|
||||
});
|
||||
let ruby = self.ruby.iter().map(outlined);
|
||||
let inlines = self.inlines.iter().map(|i| Some(m.transform_rect_bbox(i.bounds)));
|
||||
glyphs.chain(ruby).chain(inlines).flatten().reduce(|a, b| a.union(b))
|
||||
}
|
||||
/// All glyph outlines combined (e.g. for Create Outlines).
|
||||
pub fn to_bezpath(&self) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
|
||||
@@ -171,6 +171,7 @@ impl VectorcraftApp {
|
||||
tick_iteration: None,
|
||||
input_frames: vec![],
|
||||
shortcuts_frames: vec![],
|
||||
held_tool: Vec::new(),
|
||||
synthetic_injected: false,
|
||||
injected_events: vec![],
|
||||
injected_generation: InputGeneration::default(),
|
||||
|
||||
@@ -370,8 +370,10 @@ impl VectorcraftApp {
|
||||
|
||||
/// Select a tool (also used by the toolbar and shortcuts).
|
||||
pub fn select_tool(&mut self, id: &str) {
|
||||
// The tip of a pen whose eraser end selected the Eraser doesn't undo this choice.
|
||||
// The tip of a pen whose eraser end selected the Eraser doesn't undo this choice, and
|
||||
// neither does letting go of a held tool shortcut.
|
||||
self.pen_end.forget_tool();
|
||||
self.held_tool.clear();
|
||||
self.activate_tool(id, true);
|
||||
}
|
||||
|
||||
|
||||
@@ -363,6 +363,10 @@ fn end_art_move(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
|
||||
/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches).
|
||||
pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) {
|
||||
// A press lent to a selection tool (Cmd/Ctrl held) does not use the tool of a held key.
|
||||
if ev.kind == PointerKind::Down && app.session.lent_tool_id(&ev.mods).is_none() {
|
||||
crate::shortcuts::note_tool_use(app);
|
||||
}
|
||||
let r = app.session.pointer(ev, view);
|
||||
// The artboard the Artboard tool makes active is the active one (the navigator's, the
|
||||
// Artboards panel's).
|
||||
|
||||
@@ -290,6 +290,11 @@ fn begin_drag(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) {
|
||||
crate::document_drag::begin(app, ui.ctx(), ev.pos);
|
||||
Drag::Tool
|
||||
};
|
||||
// A drag of the Zoom, Hand or Rotate View tool uses the tool of a held shortcut; held Space's
|
||||
// view tools are not that tool.
|
||||
if !space && matches!(d, Drag::ZoomBox { .. } | Drag::ZoomScrub { .. } | Drag::Pan { .. } | Drag::RotateView { .. }) {
|
||||
crate::shortcuts::note_tool_use(app);
|
||||
}
|
||||
// Panning, zooming or turning the view takes the pointer from the tool's hover.
|
||||
if !tool_holds(d) {
|
||||
app.session.tool_hover_ended(view);
|
||||
|
||||
@@ -343,7 +343,10 @@ pub(crate) fn show_dialog(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// As wide as the field and as tall as the buttons, as in `dialogs::show`: a right-to-left
|
||||
// layout would take all the height left in the window and center the buttons in it.
|
||||
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
|
||||
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
@@ -445,7 +448,8 @@ pub(crate) fn show_manage(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
|
||||
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ pub mod raster_effects;
|
||||
pub mod rearrange_artboards;
|
||||
pub mod recolor;
|
||||
mod recovery;
|
||||
pub mod relink;
|
||||
pub mod rename_artboards;
|
||||
pub(crate) mod revolve;
|
||||
pub(crate) mod revolve_gizmo;
|
||||
@@ -368,6 +369,7 @@ registry! {
|
||||
OfficeExport: [office_export::KIND] => office_export::SPEC,
|
||||
DxfOptions: [dxf_options::KIND] => dxf_options::SPEC,
|
||||
PlacementOptions: [placement_options::KIND] => placement_options::SPEC,
|
||||
Relink: [relink::KIND] => relink::SPEC,
|
||||
Package: [package::KIND] => package::SPEC,
|
||||
ActionBatch: [action_batch::KIND] => action_batch::SPEC,
|
||||
ActionSetOptions: [action_set_options::KIND] => action_set_options::SPEC,
|
||||
|
||||
@@ -48,7 +48,7 @@ fn reset(_: &VectorcraftApp, d: &mut Dialog) {
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
widgets::label_row(ui, tl!("Pivot:"), LABEL_W, |ui| {
|
||||
let current = d.f64("pivot", f64::from(CENTER)).clamp(0.0, 8.0) as usize;
|
||||
if let Some(i) = widgets::reference_point(ui, current) {
|
||||
if let Some(i) = widgets::reference_point_named(ui, current, tl!("Pivot:")) {
|
||||
d.fields.insert("pivot".into(), json!(i));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
ui.add_enabled_ui(free, |ui| {
|
||||
widgets::label_row(ui, tl!("Alignment:"), LABEL, |ui| {
|
||||
let current = ALIGN.iter().position(|a| *a == d.str("align")).unwrap_or(4);
|
||||
if let Some(i) = widgets::reference_point(ui, current)
|
||||
if let Some(i) = widgets::reference_point_named(ui, current, tl!("Alignment:"))
|
||||
&& let Some(a) = ALIGN.get(i)
|
||||
{
|
||||
d.fields.insert("align".into(), json!(a));
|
||||
|
||||
@@ -628,7 +628,7 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.add_enabled_ui(!tiles && !placed, |ui| {
|
||||
let origin = choice::<Origin>(d, "placement.origin").unwrap_or_default();
|
||||
let current = Origin::ALL.iter().position(|o| *o == origin).unwrap_or(4);
|
||||
if let Some(o) = widgets::reference_point(ui, current).and_then(|i| Origin::ALL.get(i)) {
|
||||
if let Some(o) = widgets::reference_point_named(ui, current, tl!("Placement:")).and_then(|i| Origin::ALL.get(i)) {
|
||||
set(d, "placement.origin", json!(o.id()));
|
||||
}
|
||||
ui.label("X:");
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Links panel → Relink…: the selected links, and the Replace choice of which links the new file
|
||||
//! takes. Selected Instances relinks the selected images and placed documents, All Instances every
|
||||
//! object linked to the same files, and Missing Links from This Folder the selected ones and then
|
||||
//! every other missing link whose file name is in the new file's folder. Each Link Separately asks
|
||||
//! for a file for each link in turn, one OK each, and relinks them all at the last one. Every
|
||||
//! choice is one `links.relink` call: one undo step.
|
||||
//!
|
||||
//! Fields: `ids` (the images and placed documents), `links` (`[{name, ids}]`, one row per linked
|
||||
//! file, as `links.check` lists them, then one per embedded image), `scope` (each|selected|all|missingInFolder; default
|
||||
//! selected), `path` (the file; picked when empty) and `paths` (with each: the files chosen so
|
||||
//! far, one per row of `links` in order; the next OK takes `path` or picks one for the next row).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::DialogSpec;
|
||||
use super::form;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
pub(super) const KIND: &str = "relink";
|
||||
const CMD: &str = "links.relink";
|
||||
/// (value, label) of each Replace choice.
|
||||
const SCOPES: [(&str, &str); 4] = [
|
||||
("each", "Each Link Separately"),
|
||||
("selected", "Selected Instances"),
|
||||
("all", "All Instances"),
|
||||
("missingInFolder", "Missing Links from This Folder"),
|
||||
];
|
||||
const LABEL: f32 = 70.0;
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Relink").into(),
|
||||
body,
|
||||
confirm,
|
||||
ok: Some("Relink…"),
|
||||
min_width: 400.0,
|
||||
max_width: Some(460.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// Open the dialog on the images and placed documents `ids` (default: the selected ones).
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let ids = match p.get("ids").filter(|v| !v.is_null()) {
|
||||
Some(v) => v.as_array().cloned().ok_or_else(|| format!("`ids` must be an array of object ids, not {v}"))?,
|
||||
None => crate::panels::links::selected_ids(app).into_iter().map(Value::from).collect(),
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err("select the images to relink, or pass ids".into());
|
||||
}
|
||||
let check = app.session.execute("links.check", &json!({ "ids": ids })).map_err(|e| e.to_string())?;
|
||||
// One row per file: objects showing different content of one file (pages of a PDF) share it.
|
||||
let mut by_path: Vec<(Value, Value, Vec<Value>)> = vec![];
|
||||
for l in check["links"].as_array().into_iter().flatten() {
|
||||
let ids = l["ids"].as_array().cloned().unwrap_or_default();
|
||||
match by_path.iter_mut().find(|(path, _, _)| *path == l["path"]) {
|
||||
Some((_, _, all)) => all.extend(ids),
|
||||
None => by_path.push((l["path"].clone(), l["name"].clone(), ids)),
|
||||
}
|
||||
}
|
||||
let mut links: Vec<Value> = by_path.into_iter().map(|(_, name, ids)| json!({ "name": name, "ids": ids })).collect();
|
||||
// Embedded images have no link: each gets a row of its own, named as the Links panel lists it.
|
||||
let linked: Vec<&Value> = links.iter().flat_map(|l| l["ids"].as_array().into_iter().flatten()).collect();
|
||||
let mut embedded: Vec<Value> = vec![];
|
||||
for id in ids.iter().filter(|id| !linked.contains(id)) {
|
||||
if !embedded.contains(id) {
|
||||
embedded.push(id.clone());
|
||||
}
|
||||
}
|
||||
if !embedded.is_empty() {
|
||||
let list = app.session.execute("links.list", &json!({})).map_err(|e| e.to_string())?;
|
||||
let rows = list["links"].as_array().cloned().unwrap_or_default();
|
||||
for id in embedded {
|
||||
let name = rows.iter().find(|r| r["id"] == id).map_or(Value::Null, |r| r["name"].clone());
|
||||
links.push(json!({ "name": name, "ids": [id] }));
|
||||
}
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "ids": ids, "links": links, "scope": "selected", "path": "", "paths": [] })));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn rows(d: &Dialog, key: &str) -> Vec<Value> {
|
||||
d.fields.get(key).and_then(Value::as_array).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The file the next OK uses: `path`, else one picked (none: cancelled).
|
||||
fn file(app: &mut VectorcraftApp, d: &Dialog) -> Result<String, String> {
|
||||
match d.str("path") {
|
||||
p if !p.is_empty() => Ok(p),
|
||||
_ => {
|
||||
let pick = crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() };
|
||||
crate::picks::open(app, &pick).ok_or_else(|| "cancelled".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OK: relink with the chosen scope. With each, a file for the next link: the dialog stays open
|
||||
/// until every link has one, then relinks them all.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let scope = d.str("scope");
|
||||
let r = if scope == "each" {
|
||||
let links = rows(d, "links");
|
||||
let mut paths = rows(d, "paths");
|
||||
if paths.len() < links.len() {
|
||||
paths.push(json!(file(app, d)?));
|
||||
}
|
||||
if paths.len() < links.len() {
|
||||
let mut next = d.clone();
|
||||
next.fields.insert("paths".into(), json!(paths));
|
||||
next.fields.insert("path".into(), json!(""));
|
||||
app.ui.dialog = Some(next);
|
||||
return Ok(json!({ "next": links.get(paths.len()).map_or(Value::Null, |l| l["name"].clone()) }));
|
||||
}
|
||||
let entries: Vec<Value> = links.iter().zip(&paths).map(|(l, p)| json!({ "ids": l["ids"], "path": p })).collect();
|
||||
app.run(CMD, json!({ "scope": "each", "paths": entries }))?
|
||||
} else {
|
||||
let path = file(app, d)?;
|
||||
app.run(CMD, json!({ "ids": d.fields.get("ids").cloned().unwrap_or(Value::Null), "path": path, "scope": scope }))?
|
||||
};
|
||||
app.ui.dialog = None;
|
||||
let missed: Vec<&str> = r["notFound"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
app.status(crate::panels::links::relinked_status_missed(r["relinked"].as_array().map_or(0, Vec::len), &missed));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let links = rows(d, "links");
|
||||
let paths = rows(d, "paths");
|
||||
let each = d.str("scope") == "each";
|
||||
widgets::list_box(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
for (i, l) in links.iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(6.0);
|
||||
// With each: the link the next OK picks a file for is in bold.
|
||||
let font = if each && i == paths.len() { theme::semibold(12.0) } else { egui::FontId::proportional(12.0) };
|
||||
ui.label(egui::RichText::new(l["name"].as_str().unwrap_or_default()).font(font).color(t.text_strong));
|
||||
if let Some(p) = paths.get(i).and_then(Value::as_str).filter(|_| each) {
|
||||
let name = std::path::Path::new(p).file_name().map_or(p.into(), |n| n.to_string_lossy());
|
||||
ui.add(egui::Label::new(egui::RichText::new(format!("→ {name}")).size(11.5).color(t.text_dim)).truncate()).on_hover_text(p);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
form::choice(ui, d, "scope", tl!("Replace:"), (LABEL, 260.0), &SCOPES);
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) -> String {
|
||||
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
|
||||
}
|
||||
|
||||
fn png(rgb: [u8; 3]) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
let [r, g, b] = rgb;
|
||||
image::RgbaImage::from_pixel(40, 30, image::Rgba([r, g, b, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A fresh folder with the PNG files `names` → (app with a document, folder).
|
||||
fn setup(name: &str, names: &[&str]) -> (VectorcraftApp, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-relink-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("new")).unwrap();
|
||||
for n in names {
|
||||
std::fs::write(dir.join(n), png([200, 0, 0])).unwrap();
|
||||
}
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
(app, dir)
|
||||
}
|
||||
|
||||
fn place(app: &mut VectorcraftApp, path: &std::path::Path) -> u64 {
|
||||
app.run("file.place", json!({"path": path.to_string_lossy()})).unwrap()["ids"][0].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn link_path(app: &VectorcraftApp, id: u64) -> String {
|
||||
let st = app.session.active().unwrap();
|
||||
match st.doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind) {
|
||||
Some(vectorcraft_doc::NodeKind::Image(im)) => im.link.as_ref().map(|l| l.path.clone()).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_file(dir: &std::path::Path, name: &str) -> String {
|
||||
let p = dir.join("new").join(name);
|
||||
std::fs::write(&p, png([0, 0, 200])).unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relink_opens_with_the_replace_choice_and_all_instances_relinks_every_copy() {
|
||||
let (mut app, dir) = setup("all", &["photo.png", "other.png"]);
|
||||
let (a, b, c) = (place(&mut app, &dir.join("photo.png")), place(&mut app, &dir.join("photo.png")), place(&mut app, &dir.join("other.png")));
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
app.services.pick_open = None;
|
||||
assert!(!crate::menus::enabled(&app, "ui.relinkDialog"), "no file picker (the web)");
|
||||
let picked = new_file(&dir, "blue.png");
|
||||
let answer = picked.clone();
|
||||
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(answer.clone())));
|
||||
assert!(crate::menus::enabled(&app, "ui.relinkDialog"));
|
||||
app.run("ui.relinkDialog", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().expect("the dialog");
|
||||
assert_eq!((d.kind.as_str(), d.str("scope"), d.fields["links"].as_array().unwrap().len()), (KIND, "selected".to_string(), 1));
|
||||
let text = frame(&mut app);
|
||||
for label in ["Relink", "photo.png", "Replace:", "Selected Instances", "Relink…", "Cancel"] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("scope".into(), json!("all"));
|
||||
// OK picks the file.
|
||||
let r = super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(r["relinked"], json!([a, b]));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!((link_path(&app, a), link_path(&app, b)), (picked.clone(), picked.clone()));
|
||||
assert_eq!(link_path(&app, c), dir.join("other.png").to_string_lossy());
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(link_path(&app, b), dir.join("photo.png").to_string_lossy(), "one undo step");
|
||||
// Nothing selected and no ids: nothing to relink.
|
||||
app.run("select.set", json!({"ids": []})).unwrap();
|
||||
assert!(!crate::menus::enabled(&app, "ui.relinkDialog"));
|
||||
assert!(app.run("ui.relinkDialog", json!({})).is_err());
|
||||
assert!(app.run("ui.relinkDialog", json!({"ids": 3})).is_err(), "ids is a list");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_link_separately_asks_for_a_file_for_each_link_then_relinks_them_all() {
|
||||
let (mut app, dir) = setup("each", &["one.png", "two.png"]);
|
||||
let (a, b) = (place(&mut app, &dir.join("one.png")), place(&mut app, &dir.join("two.png")));
|
||||
let (x, y) = (new_file(&dir, "x.png"), new_file(&dir, "y.png"));
|
||||
let mut answers = vec![y.clone(), x.clone()];
|
||||
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| answers.pop()));
|
||||
app.run("ui.relinkDialog", json!({"ids": [a, b]})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("scope".into(), json!("each"));
|
||||
let r = super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(r, json!({"next": "two.png"}));
|
||||
let d = app.ui.dialog.as_ref().expect("asks for the next link's file");
|
||||
assert_eq!(d.fields["paths"], json!([x]));
|
||||
assert_eq!(link_path(&app, a), dir.join("one.png").to_string_lossy(), "nothing relinked yet");
|
||||
assert!(frame(&mut app).contains("→ x.png"));
|
||||
let r = super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(r["relinked"], json!([a, b]));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!((link_path(&app, a), link_path(&app, b)), (x.clone(), y.clone()));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
(link_path(&app, a), link_path(&app, b)),
|
||||
(dir.join("one.png").to_string_lossy().into_owned(), dir.join("two.png").to_string_lossy().into_owned()),
|
||||
"one undo step"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// An embedded image in the selection gets a row of its own, so Each Link Separately asks for
|
||||
/// its file too.
|
||||
#[test]
|
||||
fn embedded_images_get_their_own_rows() {
|
||||
let (mut app, dir) = setup("embedded", &["one.png"]);
|
||||
let (a, b) = (place(&mut app, &dir.join("one.png")), place(&mut app, &dir.join("one.png")));
|
||||
app.run("links.embed", json!({"ids": [b]})).unwrap();
|
||||
app.run("ui.relinkDialog", json!({"ids": [a, b, b]})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().expect("the dialog");
|
||||
let rows = d.fields["links"].as_array().unwrap();
|
||||
assert_eq!((rows.len(), &rows[0]["ids"], &rows[1]["ids"]), (2, &json!([a]), &json!([b])));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Objects showing different content of one file share its row, so Each Link Separately asks
|
||||
/// for that file once.
|
||||
#[test]
|
||||
fn one_file_with_two_contents_is_one_row() {
|
||||
let (mut app, dir) = setup("one-file", &["photo.png"]);
|
||||
let a = place(&mut app, &dir.join("photo.png"));
|
||||
std::fs::write(dir.join("photo.png"), png([0, 200, 0])).unwrap();
|
||||
let b = place(&mut app, &dir.join("photo.png"));
|
||||
app.run("ui.relinkDialog", json!({"ids": [a, b]})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().expect("the dialog");
|
||||
assert_eq!(d.fields["links"], json!([{"name": "photo.png", "ids": [a, b]}]));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_links_from_this_folder_relinks_the_others_found_there() {
|
||||
let (mut app, dir) = setup("missing", &["a.png", "b.png"]);
|
||||
let (a, b) = (place(&mut app, &dir.join("a.png")), place(&mut app, &dir.join("b.png")));
|
||||
let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned();
|
||||
app.run("document.save", json!({"path": doc})).unwrap();
|
||||
for n in ["a.png", "b.png"] {
|
||||
std::fs::remove_file(dir.join(n)).unwrap();
|
||||
}
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
crate::io::open_path(&mut app, &doc).unwrap();
|
||||
// The Linked File Not Found question is left for the Links panel.
|
||||
app.ui.dialog = None;
|
||||
let chosen = new_file(&dir, "renamed.png");
|
||||
let found = new_file(&dir, "b.png");
|
||||
app.run("ui.relinkDialog", json!({"ids": [a]})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("scope".into(), json!("missingInFolder"));
|
||||
d.fields.insert("path".into(), json!(chosen));
|
||||
let r = super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(r, json!({"relinked": [a, b], "notFound": []}));
|
||||
assert_eq!((link_path(&app, a), link_path(&app, b)), (chosen, found));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
ui.end_row();
|
||||
label(ui, tl!("Registration:"));
|
||||
let cur = d.fields.get("registration").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
if let Some(i) = widgets::reference_point(ui, cur) {
|
||||
if let Some(i) = widgets::reference_point_named(ui, cur, tl!("Registration:")) {
|
||||
d.fields.insert("registration".into(), json!(i));
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
@@ -463,6 +463,39 @@ fn dialogs_do_not_stretch_to_the_screen() {
|
||||
assert!(checked >= 10, "only {checked} dialogs drew in the shared frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_and_toolbar_dialogs_end_at_their_buttons() {
|
||||
// New Workspace, Manage Workspaces, New Toolbar and Manage Toolbars draw their own windows: each
|
||||
// ends just below its OK button, with no empty band above or below the buttons.
|
||||
for (kind, fields, window) in [
|
||||
("newWorkspace", json!({"name": "Mine"}), "dialog-workspace"),
|
||||
("manageWorkspaces", json!({"selected": "", "name": ""}), "dialog-workspace"),
|
||||
(crate::custom_toolbars::KIND, json!({"name": "Toolbar 1"}), "dialog-new-toolbar"),
|
||||
(crate::custom_toolbars::MANAGE, json!({"selected": "", "name": ""}), "dialog-manage-toolbars"),
|
||||
] {
|
||||
let mut app = app();
|
||||
open(&mut app, kind, fields);
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
|
||||
let mut ok = None;
|
||||
for _ in 0..4 {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
ok = out.shapes.iter().find_map(|c| match &c.shape {
|
||||
egui::Shape::Text(t) if t.galley.text() == "OK" => Some(t.visual_bounding_rect()),
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
let ok = ok.unwrap_or_else(|| panic!("{kind} draws no OK button"));
|
||||
let outer = ctx.memory(|m| m.area_rect(egui::Id::unique(window))).unwrap();
|
||||
let below = outer.bottom() - ok.bottom();
|
||||
assert!(below < 40.0, "{kind}: {below} pt between OK and the bottom of its {} pt window", outer.height());
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog window of `app`'s open dialog after a few frames in a `w` × 900 window.
|
||||
fn dialog_rect(app: &mut VectorcraftApp, w: f32) -> egui::Rect {
|
||||
let ctx = egui::Context::default();
|
||||
@@ -592,6 +625,8 @@ impl PluginIntField {
|
||||
kind: vectorcraft_plugins::Kind::Filter,
|
||||
description: String::new(),
|
||||
author: String::new(),
|
||||
api: 1,
|
||||
permissions: None,
|
||||
params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())],
|
||||
extensions: Vec::new(),
|
||||
budget: Default::default(),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The export progress dialog: while an export runs in the background (Background Export) a modal
|
||||
//! window shows what is being exported, how far it has got (artboard by artboard for raster
|
||||
//! exports of several artboards, file by file for Export for Screens; busy for one artboard and for
|
||||
//! formats made in one go, which report no steps) and two buttons: Cancel (`file.cancelExport`, also
|
||||
//! exports of several artboards, file by file for Export for Screens, as a share for an exporter
|
||||
//! plug-in that reports its progress; busy for one artboard and for formats made in one go, which
|
||||
//! report no steps) and two buttons: Cancel (`file.cancelExport`, also
|
||||
//! Esc) stops it and writes nothing (Export for Screens keeps the files it wrote before the
|
||||
//! Cancel), Continue in Background (`file.exportInBackground`) closes the
|
||||
//! dialog and leaves the export to the status bar.
|
||||
@@ -24,8 +25,8 @@ fn seen_id() -> egui::Id {
|
||||
}
|
||||
|
||||
/// What the dialog shows: (label, steps done, steps in all, share done, exports after this one,
|
||||
/// whether the steps are files).
|
||||
fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize, bool)> {
|
||||
/// what the steps are).
|
||||
fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize, Steps)> {
|
||||
let jobs: Vec<_> = app.background.jobs.iter().filter(|j| !j.hidden && !j.cancelled()).filter(|j| j.progress.is_some()).collect();
|
||||
let first = jobs.first()?;
|
||||
let since = ctx.data_mut(|m| {
|
||||
@@ -40,8 +41,22 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String,
|
||||
}
|
||||
let (done, total) = first.progress.as_ref().map(|p| p.counts()).unwrap_or_default();
|
||||
let frac = first.progress.as_ref().and_then(|p| p.fraction()).unwrap_or(0.0) as f32;
|
||||
let files = first.progress.as_ref().is_some_and(|p| p.counts_files());
|
||||
Some((first.label.clone(), done, total, frac, jobs.len() - 1, files))
|
||||
let steps = match &first.progress {
|
||||
Some(p) if p.counts_share() => Steps::Share,
|
||||
Some(p) if p.counts_files() => Steps::Files,
|
||||
_ => Steps::Artboards,
|
||||
};
|
||||
Some((first.label.clone(), done, total, frac, jobs.len() - 1, steps))
|
||||
}
|
||||
|
||||
/// What an export's steps are.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Steps {
|
||||
Artboards,
|
||||
/// Files (Export for Screens).
|
||||
Files,
|
||||
/// An exporter plug-in's own steps, shown as a share of the work.
|
||||
Share,
|
||||
}
|
||||
|
||||
/// Draws the window for the first export running in the background that wasn't hidden or
|
||||
@@ -49,7 +64,7 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String,
|
||||
/// of the button pressed.
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let Some((label, done, total, frac, more, files)) = shown(app, now, ctx) else { return };
|
||||
let Some((label, done, total, frac, more, steps)) = shown(app, now, ctx) else { return };
|
||||
if app.ui.dialog.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -57,8 +72,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let heading = format!("{}…", crate::i18n::msg(&label));
|
||||
let pressed = window(ctx, "export-progress", &heading, |ui| {
|
||||
// One step says nothing a spinner doesn't: the bar would sit at 0 % until the file is written.
|
||||
if total > 1 {
|
||||
let step = if files { tl!("File {n} of {total}") } else { tl!("Artboard {n} of {total}") };
|
||||
if steps == Steps::Share && total > 0 {
|
||||
share(ui, tl!("Encoding and writing the file…"), frac);
|
||||
} else if total > 1 {
|
||||
let step = if steps == Steps::Files { tl!("File {n} of {total}") } else { tl!("Artboard {n} of {total}") };
|
||||
let step = crate::i18n::fmt(step, &[("n", &(done + 1).min(total).to_string()), ("total", &total.to_string())]);
|
||||
// The count on its own line: drawn over the bar it would straddle the fill's edge.
|
||||
ui.horizontal(|ui| {
|
||||
@@ -126,6 +143,24 @@ pub fn window(ctx: &egui::Context, id: &str, heading: &str, body: impl FnOnce(&m
|
||||
pressed
|
||||
}
|
||||
|
||||
/// Work that reports the share it has done (0 to 1), in a [`window`]: `what` it does, in the UI
|
||||
/// language, and the share as a percentage, over a bar filled that far. A plug-in's own steps are
|
||||
/// shown this way.
|
||||
pub fn share(ui: &mut egui::Ui, what: &str, done: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let done = if done.is_finite() { done.clamp(0.0, 1.0) } else { 0.0 };
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(what).color(t.text_dim));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(format!("{:.0} %", done * 100.0)).color(t.text_dim));
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.add(egui::ProgressBar::new(done).desired_width(360.0).desired_height(6.0).fill(t.accent));
|
||||
// The work reports from another thread: draw what it reports next.
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
|
||||
/// Work that counts no steps, in a [`window`]: a spinner and `what` it does, in the UI language.
|
||||
pub fn busy(ui: &mut egui::Ui, what: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
||||
@@ -2638,6 +2638,14 @@ digits Malayalam Малаялам
|
||||
Relink Свързване наново
|
||||
Relink Linked Files to the Document Свързване наново на свързаните файлове към документа
|
||||
Relink to Folder… Свързване наново към папка…
|
||||
Each Link Separately Всяка връзка поотделно
|
||||
Selected Instances Избраните екземпляри
|
||||
All Instances Всички екземпляри
|
||||
Missing Links from This Folder Липсващите връзки от тази папка
|
||||
@msg scope {v} is not each|selected|all|missingInFolder scope {v} не е each|selected|all|missingInFolder
|
||||
@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each и paths вървят заедно: paths изброява записи с ids и path вместо ids, path и folder
|
||||
@msg paths entry {i} needs path and ids (object ids no other entry has) записът {i} в paths изисква path и ids (идентификатори на обекти, които няма друг запис)
|
||||
@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder приема path: в папката на този файл се търсят другите липсващи връзки
|
||||
Relink… Свързване наново…
|
||||
Find Linked Files Търсене на свързани файлове
|
||||
Find Linked Files in Folder… Търсене на свързани файлове в папка…
|
||||
@@ -2755,6 +2763,7 @@ digits Malayalam Малаялам
|
||||
Rotate Tool Инструмент „Завъртане“
|
||||
Rotate View Tool Инструмент „Завъртане на изглед“
|
||||
Trackpad Gesture to Rotate View Завъртане на изгледа с жест на тракпада
|
||||
Hold a Tool Shortcut to Use the Tool Temporarily Задържане на клавиша на инструмент за временното му използване
|
||||
Rotate… Завъртане…
|
||||
Rotation Завъртане
|
||||
Roughen… Грапавост…
|
||||
@@ -5697,6 +5706,17 @@ axis Y Axis Y ос
|
||||
The plug-in is computing… Приставката изчислява…
|
||||
Inspect Plug-in Проверка на приставка
|
||||
Cancel Plug-in Run Отказ от изпълнението на приставката
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Да се инсталира ли „{name}“, който иска тези права?
|
||||
ID: {id} ID: {id}
|
||||
Version: {version} Версия: {version}
|
||||
File SHA-256: {hash} SHA-256 на файла: {hash}
|
||||
Read the objects it is given Четене на обектите, които получава
|
||||
Change, add and delete objects in the document Промяна, добавяне и изтриване на обекти в документа
|
||||
Keep data of its own with the document Съхраняване на собствени данни в документа
|
||||
Change how the objects its effect is applied to are drawn Промяна на начина, по който се рисуват обектите, към които е приложен ефектът му
|
||||
Write the document as a file in its own format Записване на документа като файл в собствен формат
|
||||
Show its progress, see Cancel and write log lines Показване на напредъка, разпознаване на отказ и записване на редове в дневника
|
||||
# Actions panel: action sets (#3712).
|
||||
New Set… Нов набор…
|
||||
New Set Нов набор
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1298,6 +1298,7 @@ lighting Light Svjetlo
|
||||
Rotate Tool Alat za rotaciju
|
||||
Rotate View Tool Alat za rotaciju prikaza
|
||||
Trackpad Gesture to Rotate View Pokret dodirne ploče za rotaciju prikaza
|
||||
Hold a Tool Shortcut to Use the Tool Temporarily Držite prečicu alata za privremenu upotrebu alata
|
||||
Rotate View Rotiraj prikaz
|
||||
Rounded Rectangle Tool Alat zaobljeni pravougaonik
|
||||
Scale Tool Alat za skaliranje
|
||||
@@ -2066,6 +2067,14 @@ digits Malayalam Malajalam
|
||||
Location Lokacija
|
||||
No images Nema slika
|
||||
Relink to Folder… Ponovo poveži s folderom…
|
||||
Each Link Separately Svaku vezu zasebno
|
||||
Selected Instances Odabrane instance
|
||||
All Instances Sve instance
|
||||
Missing Links from This Folder Nedostajuće veze iz ovog foldera
|
||||
@msg scope {v} is not each|selected|all|missingInFolder scope {v} nije each|selected|all|missingInFolder
|
||||
@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each i paths idu zajedno: paths navodi unose s ids i path, umjesto ids, path i folder
|
||||
@msg paths entry {i} needs path and ids (object ids no other entry has) unos {i} u paths treba path i ids (ID-ove objekata koje nema nijedan drugi unos)
|
||||
@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder uzima path: u folderu te datoteke traže se ostale nedostajuće veze
|
||||
Show Missing Prikaži nedostajuće
|
||||
Sort by Status Sortiraj po statusu
|
||||
Clear Overrides Očisti zamjene
|
||||
@@ -5718,6 +5727,33 @@ axis Y Axis Y osa
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` živi efekat radi dok se dokument iscrtava, pa ne može biti `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out izvoznik uvijek dobija budžet izvoza: izostavite `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” traži veliki budžet (svako pokretanje do {minutes} minuta i {times} puta više uobičajenih instrukcija): instalirajte ga s `allowHeavy: true` kada korisnik pristane
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Instalirati „{name}”, koji traži ove dozvole?
|
||||
ID: {id} ID: {id}
|
||||
Version: {version} Verzija: {version}
|
||||
File SHA-256: {hash} SHA-256 datoteke: {hash}
|
||||
Read the objects it is given Čitati objekte koje dobije
|
||||
Change, add and delete objects in the document Mijenjati, dodavati i brisati objekte u dokumentu
|
||||
Keep data of its own with the document Čuvati vlastite podatke uz dokument
|
||||
Change how the objects its effect is applied to are drawn Mijenjati kako se crtaju objekti na koje je primijenjen njegov efekt
|
||||
Write the document as a file in its own format Zapisati dokument kao datoteku u vlastitom formatu
|
||||
Show its progress, see Cancel and write log lines Prikazati napredak, prepoznati otkazivanje i zapisivati redove dnevnika
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `progress` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} modul uvozi `env.{name}` s drugim tipom od domaćinovog: domaćinov tip je {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul uvozi `env.{name}`, koji dodatak smije koristiti samo na nivou API-ja dodataka {PROGRESS_API} (`api` {PROGRESS_API}) i s dozvolom `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul uvozi `{from}.{name}`, koji domaćin ne nudi: dodatak smije uvoziti samo `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivo API-ja dodataka 2), a nikakav WASI, datoteke ni mrežu
|
||||
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; ovaj domaćin podržava nivoe API-ja dodataka od 1 do {API_LEVEL}
|
||||
@msg `grant` must be a list of permission names `grant` mora biti lista naziva dozvola
|
||||
@msg `grant` names an unknown permission `{shown}` `grant` navodi nepoznatu dozvolu `{shown}`
|
||||
@msg a plug-in of kind `{_1}` needs the permission `{_2}` dodatku vrste `{_1}` potrebna je dozvola `{_2}`
|
||||
@msg at most {MAX_PERMISSIONS} `permissions` najviše {MAX_PERMISSIONS} `permissions`
|
||||
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` dozvola `{shown}` ne odnosi se na dodatak vrste `{_1}`
|
||||
@msg permission `{shown}` is listed twice dozvola `{shown}` navedena je dvaput
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it dozvola `{shown}` rezervisana je za kasniji API dodataka; ovaj domaćin je ne dodjeljuje
|
||||
@msg unknown permission `{shown}` nepoznata dozvola `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” traži dozvole {asked}: instalirajte ga s `grant` koji ih navodi kada korisnik pristane
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nije dobio `{p}`, što je potrebno njegovom izlazu: ništa nije promijenjeno
|
||||
@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nema SHA-256 naveden u `hash`: ništa nije instalirano
|
||||
New Set… Novi skup…
|
||||
New Set Novi skup
|
||||
Set Options… Opcije skupa…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3325,6 +3325,14 @@ digits Malayalam Malaiàlam
|
||||
Relink Torna a enllaçar
|
||||
Relink Linked Files to the Document Torna a enllaçar els fitxers enllaçats al document
|
||||
Relink to Folder… Torna a enllaçar a una carpeta…
|
||||
Each Link Separately Cada enllaç per separat
|
||||
Selected Instances Instàncies seleccionades
|
||||
All Instances Totes les instàncies
|
||||
Missing Links from This Folder Enllaços que falten d’aquesta carpeta
|
||||
@msg scope {v} is not each|selected|all|missingInFolder scope {v} no és each|selected|all|missingInFolder
|
||||
@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each i paths van junts: paths llista entrades amb ids i path, en lloc d’ids, path i folder
|
||||
@msg paths entry {i} needs path and ids (object ids no other entry has) l’entrada {i} de paths necessita path i ids (identificadors d’objecte que cap altra entrada no tingui)
|
||||
@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder pren path: a la carpeta d’aquest fitxer es cerquen els altres enllaços que falten
|
||||
Relink… Torna a enllaçar…
|
||||
Reload Plug-ins Recarrega els complements
|
||||
Remember Import Matches Recorda les coincidències d’importació
|
||||
@@ -4429,6 +4437,7 @@ digits Malayalam Malaiàlam
|
||||
Tracing Result with Outlines Resultat del traçat amb contorns
|
||||
Tracking Espaiat
|
||||
Trackpad Gesture to Rotate View Gesticulació del trackpad per girar la vista
|
||||
Hold a Tool Shortcut to Use the Tool Temporarily Mantén premuda la drecera d’una eina per usar-la temporalment
|
||||
Trademark Symbol Símbol de marca comercial
|
||||
Transform Transforma
|
||||
Transform Again Transforma de nou
|
||||
@@ -5833,6 +5842,33 @@ digits Malayalam Malaiàlam
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} no forma part d’un grup de diverses eines a la barra d’eines
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): instal·la’l amb `allowHeavy: true` quan l’usuari hi estigui d’acord
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos?
|
||||
ID: {id} ID: {id}
|
||||
Version: {version} Versió: {version}
|
||||
File SHA-256: {hash} SHA-256 del fitxer: {hash}
|
||||
Read the objects it is given Llegir els objectes que rep
|
||||
Change, add and delete objects in the document Canviar, afegir i suprimir objectes del document
|
||||
Keep data of its own with the document Desar dades pròpies amb el document
|
||||
Change how the objects its effect is applied to are drawn Canviar com es dibuixen els objectes als quals s’aplica el seu efecte
|
||||
Write the document as a file in its own format Escriure el document com un fitxer en un format propi
|
||||
Show its progress, see Cancel and write log lines Mostrar el progrés, detectar la cancel·lació i escriure línies de registre
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `progress` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} el mòdul importa `env.{name}` amb un tipus diferent del de l’amfitrió: el de l’amfitrió és {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el mòdul importa `env.{name}`, que un complement només pot fer servir al nivell d’API de complements {PROGRESS_API} (`api` {PROGRESS_API}) i amb el permís `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el mòdul importa `{from}.{name}`, que l’amfitrió no ofereix: un complement només pot importar `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivell d’API de complements 2), i cap WASI, fitxer ni xarxa
|
||||
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` és {_1}; aquest amfitrió admet els nivells d’API de complements de l’1 al {API_LEVEL}
|
||||
@msg `grant` must be a list of permission names `grant` ha de ser una llista de noms de permisos
|
||||
@msg `grant` names an unknown permission `{shown}` `grant` esmenta un permís desconegut `{shown}`
|
||||
@msg a plug-in of kind `{_1}` needs the permission `{_2}` un complement del tipus `{_1}` necessita el permís `{_2}`
|
||||
@msg at most {MAX_PERMISSIONS} `permissions` com a màxim {MAX_PERMISSIONS} `permissions`
|
||||
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permís `{shown}` no s’aplica a un complement del tipus `{_1}`
|
||||
@msg permission `{shown}` is listed twice el permís `{shown}` apareix dues vegades
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix
|
||||
@msg unknown permission `{shown}` permís desconegut `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a «{name}» no se li ha concedit `{p}`, que la seva sortida necessita: no s’ha canviat res
|
||||
@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el mòdul no té el SHA-256 que indica `hash`: no s’ha instal·lat res
|
||||
@msg “{name}” is not a file «{name}» no és un fitxer
|
||||
axis Both Tots dos
|
||||
axis Horizontal Horitzontal
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1319,6 +1319,7 @@ lighting Light Světlo
|
||||
Rotate Tool Nástroj Otočení
|
||||
Rotate View Tool Nástroj Otočení zobrazení
|
||||
Trackpad Gesture to Rotate View Otáčet zobrazení gestem na trackpadu
|
||||
Hold a Tool Shortcut to Use the Tool Temporarily Podržením klávesové zkratky nástroj dočasně použít
|
||||
Rotate View Otočení zobrazení
|
||||
Rounded Rectangle Tool Nástroj Zaoblený obdélník
|
||||
Scale Tool Nástroj Změna velikosti
|
||||
@@ -2092,6 +2093,14 @@ digits Malayalam Malajálamské
|
||||
Location Umístění
|
||||
No images Žádné obrázky
|
||||
Relink to Folder… Znovu propojit ke složce…
|
||||
Each Link Separately Každé propojení zvlášť
|
||||
Selected Instances Vybrané instance
|
||||
All Instances Všechny instance
|
||||
Missing Links from This Folder Chybějící propojení z této složky
|
||||
@msg scope {v} is not each|selected|all|missingInFolder scope {v} není each|selected|all|missingInFolder
|
||||
@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each a paths patří k sobě: paths uvádí položky s ids a path místo ids, path a folder
|
||||
@msg paths entry {i} needs path and ids (object ids no other entry has) položka {i} v paths potřebuje path a ids (ID objektů, které nemá žádná jiná položka)
|
||||
@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder bere path: ve složce tohoto souboru se hledají ostatní chybějící propojení
|
||||
Show Missing Zobrazit chybějící
|
||||
Sort by Status Seřadit podle stavu
|
||||
Clear Overrides Vymazat přepsání
|
||||
@@ -5813,6 +5822,33 @@ axis Y Axis osa Y
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): nainstalujte ho s `allowHeavy: true`, jakmile uživatel souhlasí
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění?
|
||||
ID: {id} ID: {id}
|
||||
Version: {version} Verze: {version}
|
||||
File SHA-256: {hash} SHA-256 souboru: {hash}
|
||||
Read the objects it is given Číst objekty, které dostane
|
||||
Change, add and delete objects in the document Měnit, přidávat a mazat objekty v dokumentu
|
||||
Keep data of its own with the document Ukládat vlastní data s dokumentem
|
||||
Change how the objects its effect is applied to are drawn Měnit, jak se kreslí objekty, na které je použit jeho efekt
|
||||
Write the document as a file in its own format Zapsat dokument jako soubor ve vlastním formátu
|
||||
Show its progress, see Cancel and write log lines Zobrazit průběh, rozpoznat zrušení a zapisovat řádky protokolu
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `progress` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} modul importuje `env.{name}` s jiným typem, než má hostitel: typ hostitele je {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul importuje `env.{name}`, který zásuvný modul smí používat jen na úrovni API zásuvných modulů {PROGRESS_API} (`api` {PROGRESS_API}) a s oprávněním `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul importuje `{from}.{name}`, který hostitel nenabízí: zásuvný modul smí importovat jen `env.vc_progress`, `env.vc_cancelled` a `env.vc_log` (úroveň API zásuvných modulů 2), a žádné WASI, soubory ani síť
|
||||
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; tento hostitel podporuje úrovně API zásuvných modulů 1 až {API_LEVEL}
|
||||
@msg `grant` must be a list of permission names `grant` musí být seznam názvů oprávnění
|
||||
@msg `grant` names an unknown permission `{shown}` `grant` uvádí neznámé oprávnění `{shown}`
|
||||
@msg a plug-in of kind `{_1}` needs the permission `{_2}` zásuvný modul druhu `{_1}` potřebuje oprávnění `{_2}`
|
||||
@msg at most {MAX_PERMISSIONS} `permissions` nejvýše {MAX_PERMISSIONS} `permissions`
|
||||
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` oprávnění `{shown}` se nevztahuje na zásuvný modul druhu `{_1}`
|
||||
@msg permission `{shown}` is listed twice oprávnění `{shown}` je uvedeno dvakrát
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje
|
||||
@msg unknown permission `{shown}` neznámé oprávnění `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ nedostal `{p}`, které jeho výstup potřebuje: nic se nezměnilo
|
||||
@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nemá SHA-256 uvedený v `hash`: nic nebylo nainstalováno
|
||||
# Actions panel: action sets (#3712).
|
||||
New Set… Nová sada…
|
||||
New Set Nová sada
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1232,6 +1232,7 @@ lighting Light Lys
|
||||
Rotate Tool Roteringsværktøj
|
||||
Rotate View Tool Visningsroteringsværktøj
|
||||
Trackpad Gesture to Rotate View Rotér visning med bevægelse på pegefeltet
|
||||
Hold a Tool Shortcut to Use the Tool Temporarily Hold et værktøjs genvejstast nede for at bruge værktøjet midlertidigt
|
||||
Rotate View Rotér visning
|
||||
Rounded Rectangle Tool Afrundet rektangelværktøj
|
||||
Scale Tool Skaleringsværktøj
|
||||
@@ -1898,6 +1899,14 @@ digits Malayalam Malayalam
|
||||
Location Placering
|
||||
No images Ingen billeder
|
||||
Relink to Folder… Genopret link til mappe…
|
||||
Each Link Separately Hvert link for sig
|
||||
Selected Instances Valgte forekomster
|
||||
All Instances Alle forekomster
|
||||
Missing Links from This Folder Manglende links fra denne mappe
|
||||
@msg scope {v} is not each|selected|all|missingInFolder scope {v} er ikke each|selected|all|missingInFolder
|
||||
@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each og paths hører sammen: paths angiver poster med ids og path i stedet for ids, path og folder
|
||||
@msg paths entry {i} needs path and ids (object ids no other entry has) post {i} i paths skal have path og ids (objekt-id'er, som ingen anden post har)
|
||||
@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder tager path: der søges efter de andre manglende links i den fils mappe
|
||||
Show Missing Vis manglende
|
||||
Sort by Status Sortér efter status
|
||||
Clear Overrides Ryd tilsidesættelser
|
||||
@@ -5712,6 +5721,17 @@ axis Y Axis Y-akse
|
||||
The plug-in is computing… Plug-in'en regner…
|
||||
Inspect Plug-in Undersøg plug-in
|
||||
Cancel Plug-in Run Annullér plug-in-kørsel
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Installér »{name}«, som beder om disse rettigheder?
|
||||
ID: {id} ID: {id}
|
||||
Version: {version} Version: {version}
|
||||
File SHA-256: {hash} Filens SHA-256: {hash}
|
||||
Read the objects it is given Læse de objekter, den får
|
||||
Change, add and delete objects in the document Ændre, tilføje og slette objekter i dokumentet
|
||||
Keep data of its own with the document Gemme sine egne data med dokumentet
|
||||
Change how the objects its effect is applied to are drawn Ændre, hvordan de objekter, dens effekt er anvendt på, tegnes
|
||||
Write the document as a file in its own format Skrive dokumentet som en fil i sit eget format
|
||||
Show its progress, see Cancel and write log lines Vise sin fremdrift, se en annullering og skrive logrækker
|
||||
# Actions panel: action sets (#3712).
|
||||
New Set… Nyt sæt…
|
||||
New Set Nyt sæt
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2665,6 +2665,14 @@ digits Malayalam Malayalam
|
||||
Relink Neu verknüpfen
|
||||
Relink Linked Files to the Document Verknüpfte Dateien neu mit dem Dokument verknüpfen
|
||||
Relink to Folder… Mit Ordner neu verknüpfen…
|
||||
Each Link Separately Jede Verknüpfung einzeln
|
||||
Selected Instances Ausgewählte Instanzen
|
||||
All Instances Alle Instanzen
|
||||
Missing Links from This Folder Fehlende Verknüpfungen aus diesem Ordner
|
||||
@msg scope {v} is not each|selected|all|missingInFolder scope {v} ist nicht each|selected|all|missingInFolder
|
||||
@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each und paths gehören zusammen: paths listet Einträge mit ids und path anstelle von ids, path und folder
|
||||
@msg paths entry {i} needs path and ids (object ids no other entry has) paths-Eintrag {i} braucht path und ids (Objekt-IDs, die kein anderer Eintrag hat)
|
||||
@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder nimmt path: Im Ordner dieser Datei wird nach den anderen fehlenden Verknüpfungen gesucht
|
||||
Relink… Neu verknüpfen…
|
||||
Find Linked Files Verknüpfte Dateien suchen
|
||||
Find Linked Files in Folder… Verknüpfte Dateien in Ordner suchen…
|
||||
@@ -2783,6 +2791,7 @@ digits Malayalam Malayalam
|
||||
Rotate Tool Drehen-Werkzeug
|
||||
Rotate View Tool Ansichtdrehung-Werkzeug
|
||||
Trackpad Gesture to Rotate View Ansicht mit Trackpad-Geste drehen
|
||||
Hold a Tool Shortcut to Use the Tool Temporarily Werkzeug-Tastaturbefehl gedrückt halten, um das Werkzeug vorübergehend zu verwenden
|
||||
Rotate… Drehen…
|
||||
Rotation Drehung
|
||||
Roughen… Aufrauen…
|
||||
@@ -5395,6 +5404,33 @@ axis Y Axis Y-Achse
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein
|
||||
@msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Installieren Sie es mit `allowHeavy: true`, sobald der Benutzer zustimmt
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt?
|
||||
ID: {id} ID: {id}
|
||||
Version: {version} Version: {version}
|
||||
File SHA-256: {hash} Datei-SHA-256: {hash}
|
||||
Read the objects it is given Die Objekte lesen, die es erhält
|
||||
Change, add and delete objects in the document Objekte im Dokument ändern, hinzufügen und löschen
|
||||
Keep data of its own with the document Eigene Daten mit dem Dokument speichern
|
||||
Change how the objects its effect is applied to are drawn Ändern, wie die Objekte gezeichnet werden, auf die sein Effekt angewendet ist
|
||||
Write the document as a file in its own format Das Dokument als Datei in einem eigenen Format schreiben
|
||||
Show its progress, see Cancel and write log lines Den Fortschritt anzeigen, ein Abbrechen erkennen und Protokollzeilen schreiben
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `progress` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} das Modul importiert `env.{name}` mit einem anderen Typ als dem des Hosts: Der Typ des Hosts ist {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` das Modul importiert `env.{name}`, das ein Plug-in nur auf der Plug-in-API-Stufe {PROGRESS_API} (`api` {PROGRESS_API}) und mit der Berechtigung `progress` verwenden darf
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network das Modul importiert `{from}.{name}`, das der Host nicht anbietet: Ein Plug-in darf nur `env.vc_progress`, `env.vc_cancelled` und `env.vc_log` importieren (Plug-in-API-Stufe 2), aber kein WASI, keine Dateien und kein Netzwerk
|
||||
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ist {_1}; dieser Host unterstützt die Plug-in-API-Stufen 1 bis {API_LEVEL}
|
||||
@msg `grant` must be a list of permission names `grant` muss eine Liste von Berechtigungsnamen sein
|
||||
@msg `grant` names an unknown permission `{shown}` `grant` nennt eine unbekannte Berechtigung `{shown}`
|
||||
@msg a plug-in of kind `{_1}` needs the permission `{_2}` ein Plug-in der Art `{_1}` braucht die Berechtigung `{_2}`
|
||||
@msg at most {MAX_PERMISSIONS} `permissions` höchstens {MAX_PERMISSIONS} `permissions`
|
||||
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` die Berechtigung `{shown}` gilt nicht für ein Plug-in der Art `{_1}`
|
||||
@msg permission `{shown}` is listed twice die Berechtigung `{shown}` ist doppelt aufgeführt
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht
|
||||
@msg unknown permission `{shown}` unbekannte Berechtigung `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ wurde `{p}` nicht gewährt, das seine Ausgabe braucht: Es wurde nichts geändert
|
||||
@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed das Modul hat nicht den SHA-256-Wert aus `hash`: Es wurde nichts installiert
|
||||
# Actions panel: action sets (#3712).
|
||||
New Set… Neuer Satz…
|
||||
New Set Neuer Satz
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user