diff --git a/Cargo.lock b/Cargo.lock index d06f43371..72d591b91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 1f81e7569..a3e3e9890 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs index cea213cd6..95abdf2f3 100644 --- a/crates/color/src/cms/generic.rs +++ b/crates/color/src/cms/generic.rs @@ -231,6 +231,21 @@ impl GenericCmyk { } g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::(); } + // 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; diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 99c16edf4..ecb4c1c59 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -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(); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 40a1c78b0..508ce247f 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -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::{ diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 78e1a5bf0..a58ae9c27 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -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], 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]) -> Option { + 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; + +/// 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, Option)>, + /// By the type's content. + content: std::collections::HashMap>, +} + +/// 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, +} + +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, t: &crate::TextObject, map: &TypeMap, inputs: u64) -> Result, Box> { + // 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, missing: Missing, ink: Option) { + 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, by_node: NodeKey, ink: Option) { + 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 { + 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 = 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, t: &crate::TextObject, m: Affine, projection: Option<(Homography, Affine)>) -> Option> { + 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) -> 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, stroked: bool, to: Affine) -> Option { + 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, stroked: bool, to: Affine, typed: &std::collections::HashSet) -> Option { + // `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], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option { 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], 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, linear: Affine, measured: &mut usize) -> Option { + 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 { + 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); + } +} diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 528e33e3e..5caada836 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -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, + /// 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, &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(); diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index a257e2e0a..f189801bc 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -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. diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index ee88f485c..c4b6be4b7 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -39,6 +39,8 @@ pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option { /// 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(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 }) diff --git a/crates/effects/tests/envelope_ink.rs b/crates/effects/tests/envelope_ink.rs new file mode 100644 index 000000000..a95906cc1 --- /dev/null +++ b/crates/effects/tests/envelope_ink.rs @@ -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:?}"); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6789a2116..eddf966db 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 98dfe3aaa..a26c75da2 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -575,21 +575,26 @@ pub(crate) fn named_id(p: &Value) -> Result> { } } -/// Objects a command targets: explicit `ids` param or the selection. -pub(crate) fn targets(s: &Session, p: &Value) -> Result> { - // 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> { + 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> { + match named_ids(p)? { + Some(ids) => Ok(ids), + None => Ok(s.doc()?.selection.objects.clone()), } } diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index df55e5207..dbca59b00 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -14,7 +14,7 @@ pub(super) fn curvature(s: &mut Session, p: &Value) -> Result { 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]); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index e6568c5ea..21f43e0ee 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -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 { 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")); diff --git a/crates/engine/src/cmd/fileio/progress.rs b/crates/engine/src/cmd/fileio/progress.rs index d2e039348..2d23ef6cd 100644 --- a/crates/engine/src/cmd/fileio/progress.rs +++ b/crates/engine/src/cmd/fileio/progress.rs @@ -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(progress: &Arc, 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)); + } } diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index cf5887074..0a81a947e 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -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 { @@ -336,7 +336,7 @@ fn release(s: &mut Session, p: &Value) -> Result { 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 { } fn locate(s: &mut Session, p: &Value) -> Result { + 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; diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 4573ea2c2..89492ad01 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -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 { vec![ @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "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>, 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 { + 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, 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, 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 { 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 { 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 { _ => None, }) }; - let ids: Vec = match ids_param(p, "ids") { - Some(ids) => ids, - None => { + let mut ids: Vec = 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 = 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 { 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 = 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, 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 = 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 { } } } - (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 { 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, Vec) = (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 { 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, + images_only: bool, + files: &mut Vec<(Vec, String)>, + not_found: &mut Vec, +) { + 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 = 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 { - 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 { /// `id`, else the first selected image or placed document. fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { 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)), diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 9933b2071..7d7c21227 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -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( label: &str, f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec, &mut [Arc]) -> Result, ) -> Result { - 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`"))?, }; diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 20dd94fb3..54c65b0c7 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -136,7 +136,7 @@ pub fn specs() -> Vec { "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 { "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 { "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 { "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 ), diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 7583cc52f..521f092b0 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -84,7 +84,7 @@ pub(super) fn env_make_mesh(s: &mut Session, p: &Value) -> Result { let roots = roots_param(s, p)?; let st = s.doc()?; let nodes: Vec> = 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)))?; diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 65bf15086..01721471c 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -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 { 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 { fn view_mask(s: &mut Session, p: &Value) -> Result { 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 { 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(_) => {} diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index bac3540fa..ade05bd95 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -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}; diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 2e7d2a8e4..7aef60fca 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -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 { } pub(super) fn isolate(s: &mut Session, p: &Value) -> Result { - 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; diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index d78ca2782..0de33a22c 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -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 { pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { 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")), diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 8d1bb0408..6593f7a79 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -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 { 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)> { } } +/// 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>> { + 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 { 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 { ), )); } + // 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 { - 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 { + 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)> { 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> = 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, 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::>()}) } @@ -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 { 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 { - 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::>(), "folder": folder})) } @@ -402,6 +483,9 @@ fn info(_: &mut Session, p: &Value) -> Result { fn remove(s: &mut Session, p: &Value) -> Result { 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 { "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.), format? and extensions? (an exporter's format id, plugin., 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.), format? and extensions? (an exporter's format id, plugin., 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 { "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 { "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 { "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 ), diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index f0829932b..a702f1580 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -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), diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 511e8ce4f..44ec938bc 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -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 { - let id = id_param(p, "id") +fn target(s: &Session, p: &Value, cmd: &str) -> Result { + 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 { - 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 { Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) } fn expand(s: &mut Session, p: &Value) -> Result { - 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; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index a15ea9ff4..36780707f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -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 { @@ -290,7 +290,7 @@ fn style_index(d: &Document, name: &str) -> Result { /// 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`)"))?, }; diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 7944e59a0..f19861685 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -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 { @@ -195,10 +194,11 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { 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 { /// 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, colors: &mut Vec) { + let by_name = swatches_by_name(d); + let mut seen_swatches: HashSet = swatches.iter().cloned().collect(); + let mut seen_colors: HashSet = 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 { + 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 { + 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>); + +/// 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> { + 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::>()?)) +} + +/// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN. +fn stop_key(s: &GradientStop) -> Option> { + 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 = d.swatches_iter().map(|w| w.name.clone()).collect(); + let live: HashSet = d.swatches_iter().chain([vectorcraft_color::swatch::registration()]).map(|w| w.name.clone()).collect(); + let mut by_color: HashMap = 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 { } // 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 { 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 = 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 { fn swatch_unused(s: &mut Session, _: &Value) -> Result { 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 { } } } + 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 = colors.iter().filter_map(color_key).collect(); + let patterns: HashSet<&String> = patterns.iter().collect(); + let gradients: HashSet = 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]; diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 0c365919b..618ef2ee3 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -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 { 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::>>(); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index fba84acaf..29d1de28f 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1676,7 +1676,7 @@ pub(crate) fn type_ranges( at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>, ) -> Result> { // (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")); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index f8ce2d691..17b2f0f0c 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -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 Result>> { +/// 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>> { 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 Result>> { - 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> { - 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 { /// (`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>) -> Result { 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 { 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 Result { 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") } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 7730075ea..82946ac29 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -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, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index f2104d75f..ec9fdbac1 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -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. diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 0e85391fc..02f2bb532 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -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) { + 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 = 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| 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, + /// The entries it can't use (a permission it doesn't know, an invalid id), as they were. + other: Vec, +} + +/// 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 { + 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::>(&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::(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 { + 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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e2fe63c17..b9e247ed9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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(); diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs index 8e3cbf6cc..e4d030d38 100644 --- a/crates/engine/src/tests/envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -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, 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 = 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"); +} diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index fd1217c10..cfe43ee8e 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -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 = 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. diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index e3e85d229..7616a4529 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -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). diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 194c56178..fdfe60067 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -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 { + 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::>(&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 = (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] diff --git a/crates/engine/src/tests/swatchcmds.rs b/crates/engine/src/tests/swatchcmds.rs index b0d0edf86..2a74e194d 100644 --- a/crates/engine/src/tests/swatchcmds.rs +++ b/crates/engine/src/tests/swatchcmds.rs @@ -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 { + 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> = ids.iter().map(|id| link(&s, *id)).collect(); + assert_eq!(got, want.map(|i: Option| 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 = 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::(&json).unwrap(), *doc(&s)); } diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 6a92ec0d9..5f16715da 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -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]})), diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 1ffbbe7d8..73c497032 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -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::(), layers in any::(), + 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::())), ) { 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 { "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", diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs new file mode 100644 index 000000000..6c84c66b3 --- /dev/null +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -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 = 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); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index a6f0b02c7..3689d9cae 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -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; diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index ac982a20e..1791555f7 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -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::(), any::()).prop_map(|(d, t)| format!("(call $vc_progress (i64.const {d}) (i64.const {t}))")), + Just("(drop (call $vc_cancelled))".to_string()), + (prop_oneof![any::(), -1..5i32], prop_oneof![any::(), 0x1ff00..0x20100i32], prop_oneof![any::(), 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::(), 0..64), + effect in any::(), + ) { + 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::() <= MAX_LOG_BYTES); + if let Some((done, total)) = report.progress { + prop_assert!(total == 0 || done <= total); + } + } } diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 0bb41977f..47fdc34fe 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -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 = 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 +} diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index 3141220aa..a1b24662e 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -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" => { diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index fa5963df4..6e758fd79 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -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 { diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 307105f95..45f60916b 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -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 }; diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 7564e0c2e..3d95c9485 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -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" => {} diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index b8cb12218..e227ec12b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -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 = 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() { diff --git a/crates/eps/tests/envelope_ink.rs b/crates/eps/tests/envelope_ink.rs new file mode 100644 index 000000000..eccbf8a8f --- /dev/null +++ b/crates/eps/tests/envelope_ink.rs @@ -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:?}"); +} diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml new file mode 100644 index 000000000..0108b50d9 --- /dev/null +++ b/crates/js/Cargo.toml @@ -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 } diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs new file mode 100644 index 000000000..f0dd6b44b --- /dev/null +++ b/crates/js/src/ast.rs @@ -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; + +/// A property name in an object literal. +#[derive(Clone, Debug, PartialEq)] +pub enum PropertyKey { + Ident(String), + String(Vec), + Number(f64), +} + +impl PropertyKey { + /// The key as the string a property is stored under (a number through ToString). + pub fn to_utf16(&self) -> Vec { + 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), + RegExp { + pattern: Vec, + flags: String, + }, + /// An array literal; `None` is a hole (`[1, , 3]`). + Array(Vec>), + Object(Vec), + Function(FunctionId), + /// `object.property`. + Member { + object: ExprId, + property: Name, + }, + /// `object[index]`. + Index { + object: ExprId, + index: ExprId, + }, + Call { + callee: ExprId, + args: Vec, + }, + New { + callee: ExprId, + args: Vec, + }, + 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), + /// 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, +} + +/// 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), + Expr(ExprId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SwitchCase { + /// `None` for `default`. + pub test: Option, + pub body: Vec, + pub span: Span, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CatchClause { + pub param: Name, + pub body: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Stmt { + Var(Vec), + FunctionDecl(FunctionId), + Expr(ExprId), + Block(Vec), + Empty, + If { test: ExprId, consequent: StmtId, alternate: Option }, + DoWhile { body: StmtId, test: ExprId }, + While { test: ExprId, body: StmtId }, + For { init: Option, test: Option, update: Option, body: StmtId }, + ForIn { target: ForInTarget, object: ExprId, body: StmtId }, + Continue(Option), + Break(Option), + Return(Option), + With { object: ExprId, body: StmtId }, + Switch { discriminant: ExprId, cases: Vec }, + Labeled { label: Name, body: StmtId }, + Throw(ExprId), + Try { block: Vec, handler: Option, finalizer: Option> }, + 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, + pub params: Vec, + pub body: Vec, + /// 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, +} + +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, + /// 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, + 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() + } +} diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs new file mode 100644 index 000000000..75c0b92aa --- /dev/null +++ b/crates/js/src/chars.rs @@ -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 { + char::from_u32(c).map(get_general_category) +} + +/// The value of a hexadecimal digit. +pub fn hex_value(c: u32) -> Option { + 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) +} diff --git a/crates/js/src/error.rs b/crates/js/src/error.rs new file mode 100644 index 000000000..9c17bf8be --- /dev/null +++ b/crates/js/src/error.rs @@ -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"; diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs new file mode 100644 index 000000000..9b16b16c7 --- /dev/null +++ b/crates/js/src/lexer.rs @@ -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 { + self.src.get(pos).map(|&u| u32::from(u)) + } + + fn peek(&self) -> Option { + self.unit(self.pos) + } + + fn peek_at(&self, n: usize) -> Option { + 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) -> 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) -> 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) -> Result { + 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 { + 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 { + 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), +} + +/// 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}"), + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs new file mode 100644 index 000000000..4d8d32271 --- /dev/null +++ b/crates/js/src/lib.rs @@ -0,0 +1,149 @@ +//! JavaScript for VectorCraft's scripts: an ECMAScript 5.1 lexer and parser +//! (), 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 { + 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 { + 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, 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, 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, + /// 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; diff --git a/crates/js/src/number.rs b/crates/js/src/number.rs new file mode 100644 index 000000000..4afd48076 --- /dev/null +++ b/crates/js/src/number.rs @@ -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::().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:?}"); + } + } +} diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs new file mode 100644 index 000000000..d56b75617 --- /dev/null +++ b/crates/js/src/parser.rs @@ -0,0 +1,1236 @@ +//! The parser: tokens into a [`Program`] (ECMA-262 5.1 sections 11 to 14), with the early errors +//! of section 16 and of strict mode (Annex C). +//! +//! It is a recursive-descent parser whose recursion is counted: each statement, assignment +//! expression, primary expression and function body it enters adds one to a depth, and going past +//! [`crate::Options::max_depth`] gives a `SyntaxError`. Binary operators are read with an operator +//! stack, and prefix operators, assignment chains, `else if` chains and labels with loops, so long +//! runs of them do not nest at all. + +use std::collections::{HashMap, HashSet}; + +use crate::LineTable; +use crate::ast::{ + AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionKind, LogicalOp, Name, Program, Property, + PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, +}; +use crate::error::{SyntaxError, TOO_DEEP}; +use crate::lexer::{Goal, Lexer}; +use crate::token::{Directive, Keyword, Punct, Span, Token, TokenKind, strict_reserved}; + +type Result = std::result::Result; + +/// What the code being parsed allows, saved and replaced at each function body. +#[derive(Clone, Debug, Default)] +struct Context { + strict: bool, + in_function: bool, + /// The labels in scope, each with whether it labels an iteration statement (`continue` may + /// name only those). Labels in scope have distinct names. + labels: HashMap, + /// Iteration statements open in the current function. + loops: u32, + /// Iteration and `switch` statements open in the current function. + breakables: u32, +} + +/// A binary operator, with the logical ones apart. +#[derive(Clone, Copy, Debug)] +enum BinKind { + Binary(BinaryOp), + Logical(LogicalOp), +} + +/// A prefix operator. +#[derive(Clone, Copy, Debug)] +enum Prefix { + Unary(UnaryOp), + Update(UpdateOp), +} + +pub(crate) struct Parser<'s> { + src: &'s [u16], + lex: Lexer<'s>, + lines: LineTable, + tok: Token, + /// Where the last token read ends. + prev_end: u32, + ast: Ast, + depth: usize, + max_depth: usize, + ctx: Context, + directives: Vec, + /// The tokens read, kept for [`crate::tokenize`]. + tokens: Option>, + /// The next statement may be a function declaration outside strict mode code: it is an `if` + /// branch or the body of a label in a statement list (ECMA-262 2015 Annex B.3.2 and B.3.4). + /// Read and cleared as each statement starts. + function_allowed: bool, + /// The next statement stands in a statement list, where a label's body may be a function + /// declaration. Read and cleared as each statement starts. + in_list: bool, +} + +impl<'s> Parser<'s> { + pub(crate) fn new(src: &'s [u16], options: &crate::Options, keep_tokens: bool) -> Result { + let lex = Lexer::new(src, options.preprocessor_lines, options.max_depth); + let placeholder = + Token { kind: TokenKind::Eof, span: Span::default(), line: 1, column: 1, newline_before: false, escaped: false, legacy_octal: false }; + let mut parser = Self { + src, + lex, + lines: LineTable::new(src), + tok: placeholder, + prev_end: 0, + ast: Ast::default(), + depth: 0, + max_depth: options.max_depth, + ctx: Context { strict: options.strict, ..Context::default() }, + directives: Vec::new(), + tokens: keep_tokens.then(Vec::new), + function_allowed: false, + in_list: false, + }; + parser.tok = parser.next_significant()?; + Ok(parser) + } + + pub(crate) fn parse_program(mut self) -> Result<(Program, Vec)> { + let body = self.parse_body(false)?; + if self.tok.kind != TokenKind::Eof { + return Err(self.unexpected()); + } + if let Some(tokens) = &mut self.tokens { + tokens.push(self.tok.clone()); + } + let program = Program { ast: self.ast, body, strict: self.ctx.strict, directives: self.directives, lines: self.lines }; + Ok((program, self.tokens.unwrap_or_default())) + } + + // ---- tokens ---- + + /// The next token that is not a preprocessor line; preprocessor lines are kept. + fn next_significant(&mut self) -> Result { + loop { + let token = self.lex.next_token(Goal::Div)?; + if let TokenKind::Directive(directive) = &token.kind { + self.directives.push(directive.clone()); + if let Some(tokens) = &mut self.tokens { + tokens.push(token); + } + continue; + } + return Ok(token); + } + } + + /// Reads the current token and moves to the next. + fn bump(&mut self) -> Result { + // Kept before reading on, so a preprocessor line after the token comes after it. + if let Some(tokens) = &mut self.tokens { + tokens.push(self.tok.clone()); + } + let next = self.next_significant()?; + let token = std::mem::replace(&mut self.tok, next); + self.prev_end = token.span.end; + Ok(token) + } + + /// The token after the current one, without reading it. `None` when the lexer cannot read one + /// (reading it for real reports the error). + fn peek(&self) -> Option { + let mut lex = self.lex.clone(); + loop { + match lex.next_token(Goal::Div) { + Ok(Token { kind: TokenKind::Directive(_), .. }) => {} + Ok(token) => return Some(token), + Err(_) => return None, + } + } + } + + fn at(&self, p: Punct) -> bool { + self.tok.is_punct(p) + } + + fn at_keyword(&self, k: Keyword) -> bool { + self.tok.is_keyword(k) + } + + fn eat(&mut self, p: Punct) -> Result { + if self.at(p) { + self.bump()?; + return Ok(true); + } + Ok(false) + } + + fn expect(&mut self, p: Punct) -> Result { + if self.at(p) { + return self.bump(); + } + Err(self.error_at_token(&self.tok, format!("expected '{}' but found {}", p.as_str(), describe(&self.tok)))) + } + + fn expect_keyword(&mut self, k: Keyword) -> Result { + if self.at_keyword(k) { + return self.bump(); + } + Err(self.error_at_token(&self.tok, format!("expected '{}' but found {}", k.as_str(), describe(&self.tok)))) + } + + /// Ends a statement: a `;`, or one inserted before `}`, the end of the source or a token on a + /// new line (section 7.9). + fn semicolon(&mut self) -> Result<()> { + if self.eat(Punct::Semicolon)? || self.at(Punct::RBrace) || self.tok.kind == TokenKind::Eof || self.tok.newline_before { + return Ok(()); + } + Err(self.unexpected()) + } + + // ---- errors ---- + + fn error_at_offset(&self, offset: u32, message: impl Into) -> SyntaxError { + let (line, column) = self.lines.line_column(offset); + SyntaxError { message: message.into(), line, column, offset } + } + + fn error_at_token(&self, token: &Token, message: impl Into) -> SyntaxError { + SyntaxError { message: message.into(), line: token.line, column: token.column, offset: token.span.start } + } + + fn unexpected(&self) -> SyntaxError { + self.error_at_token(&self.tok, format!("unexpected {}", describe(&self.tok))) + } + + /// Counts one more level of nesting; past the limit, a `SyntaxError` at the current token. + fn enter(&mut self) -> Result<()> { + if self.depth >= self.max_depth { + return Err(self.error_at_token(&self.tok, TOO_DEEP)); + } + self.depth += 1; + Ok(()) + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + fn span_from(&self, start: Span) -> Span { + Span::new(start.start, self.prev_end.max(start.start)) + } + + // ---- names ---- + + /// Checks a name used as an identifier: not a reserved word written with escapes, and not a + /// strict-mode reserved word in strict code. + fn check_identifier(&self, token: &Token, name: &str) -> Result<()> { + if token.escaped && Keyword::from_word(name).is_some() { + return Err(self.error_at_token(token, format!("keyword '{name}' must not contain escaped characters"))); + } + if self.ctx.strict && strict_reserved(name) { + return Err(self.error_at_token(token, format!("'{name}' is a reserved word in strict mode"))); + } + Ok(()) + } + + /// Reads an identifier that a declaration binds: checked as [`Self::check_identifier`] does, + /// and in strict code neither `eval` nor `arguments`. + fn binding_identifier(&mut self) -> Result { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected an identifier but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + if self.ctx.strict && is_eval_or_arguments(&name) { + return Err(self.error_at_token(&self.tok, format!("'{name}' cannot be declared in strict mode"))); + } + let token = self.bump()?; + Ok(Name { name, span: token.span }) + } + + // ---- program and function bodies ---- + + /// Reads source elements up to `}` (in a function body) or the end of the source, reading the + /// directive prologue at their start (section 14.1). A `"use strict"` directive makes the rest + /// strict mode code. + fn parse_body(&mut self, in_function: bool) -> Result> { + let mut body = Vec::new(); + let mut prologue = true; + // A string with an octal escape before a `"use strict"` directive in the same prologue. + let mut octal: Option = None; + while !(self.tok.kind == TokenKind::Eof || (in_function && self.at(Punct::RBrace))) { + if prologue && matches!(self.tok.kind, TokenKind::String(_)) { + let first = self.tok.clone(); + let stmt = self.parse_statement_item()?; + body.push(stmt); + if !self.is_directive(stmt, &first) { + prologue = false; + continue; + } + if self.is_use_strict(&first) { + self.ctx.strict = true; + if let Some(octal) = &octal { + return Err(self.error_at_token(octal, "octal escape sequences are not allowed in strict mode")); + } + } + if first.legacy_octal && octal.is_none() { + octal = Some(first); + } + continue; + } + prologue = false; + body.push(self.parse_statement_item()?); + } + Ok(body) + } + + /// Whether `stmt`, which started at `first`, is a directive: an expression statement made of + /// that one string literal. + fn is_directive(&self, stmt: StmtId, first: &Token) -> bool { + match self.ast.stmt(stmt) { + Some(Stmt::Expr(e)) => matches!(self.ast.expr(*e), Some(Expr::String(_))) && self.ast.expr_span(*e) == first.span, + _ => false, + } + } + + /// The string token is exactly `"use strict"` or `'use strict'`, without escapes or line + /// continuations. + fn is_use_strict(&self, token: &Token) -> bool { + let raw = self.src.get(token.span.start as usize..token.span.end as usize).unwrap_or_default(); + raw.len() == 12 && raw.get(1..11).is_some_and(|inner| inner.iter().copied().eq("use strict".encode_utf16())) + } + + // ---- statements ---- + + /// A source element: a function declaration or a statement. + fn parse_statement_item(&mut self) -> Result { + if self.at_keyword(Keyword::Function) { + self.enter()?; + let result = self.parse_function_declaration(); + self.leave(); + return result; + } + self.in_list = true; + self.parse_statement() + } + + fn parse_statement(&mut self) -> Result { + self.enter()?; + let result = self.statement(); + self.leave(); + result + } + + /// A statement where Annex B lets sloppy code put a function declaration: an `if` branch. + fn parse_branch(&mut self) -> Result { + self.function_allowed = !self.ctx.strict; + self.parse_statement() + } + + fn statement(&mut self) -> Result { + let start = self.tok.span; + let function_allowed = std::mem::take(&mut self.function_allowed); + let in_list = std::mem::take(&mut self.in_list); + match &self.tok.kind { + TokenKind::Keyword(Keyword::Function) => { + if !function_allowed { + let message = if self.ctx.strict { + "in strict mode code, functions can only be declared at top level or inside a block" + } else { + "a function declaration cannot stand here; put it in a block" + }; + return Err(self.error_at_token(&self.tok, message)); + } + self.parse_function_declaration() + } + TokenKind::Ident(_) if self.peek().is_some_and(|t| t.is_punct(Punct::Colon)) => self.parse_labeled(in_list), + _ => self.plain_statement(start), + } + } + + fn plain_statement(&mut self, start: Span) -> Result { + match &self.tok.kind { + TokenKind::Punct(Punct::LBrace) => { + let body = self.parse_block()?; + Ok(self.ast.push_stmt(Stmt::Block(body), self.span_from(start))) + } + TokenKind::Punct(Punct::Semicolon) => { + self.bump()?; + Ok(self.ast.push_stmt(Stmt::Empty, start)) + } + TokenKind::Keyword(k) => self.keyword_statement(*k, start), + _ => { + let expr = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Expr(expr), self.span_from(start))) + } + } + } + + fn keyword_statement(&mut self, k: Keyword, start: Span) -> Result { + match k { + Keyword::Var => { + self.bump()?; + let decls = self.parse_var_decls(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Var(decls), self.span_from(start))) + } + Keyword::If => self.parse_if(), + Keyword::Do => self.parse_do_while(start), + Keyword::While => self.parse_while(start), + Keyword::For => self.parse_for(start), + Keyword::Continue | Keyword::Break => self.parse_jump(k == Keyword::Break, start), + Keyword::Return => self.parse_return(start), + Keyword::With => self.parse_with(start), + Keyword::Switch => self.parse_switch(start), + Keyword::Throw => self.parse_throw(start), + Keyword::Try => self.parse_try(start), + Keyword::Debugger => { + self.bump()?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Debugger, self.span_from(start))) + } + _ => { + let expr = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Expr(expr), self.span_from(start))) + } + } + } + + /// `{ … }`: its statements. + fn parse_block(&mut self) -> Result> { + self.expect(Punct::LBrace)?; + let mut body = Vec::new(); + while !self.at(Punct::RBrace) { + if self.tok.kind == TokenKind::Eof { + return Err(self.unexpected()); + } + body.push(self.parse_statement_item()?); + } + self.bump()?; + Ok(body) + } + + /// `var` declarations after the `var`. `no_in` leaves `in` to a `for (… in …)` around them. + fn parse_var_decls(&mut self, no_in: bool) -> Result> { + let mut decls = Vec::new(); + loop { + let name = self.binding_identifier()?; + let init = if self.eat(Punct::Eq)? { Some(self.parse_assignment(no_in)?) } else { None }; + decls.push(VarDecl { name, init }); + if !self.eat(Punct::Comma)? { + return Ok(decls); + } + } + } + + /// `if (…) … else if (…) … else …`, the `else if` chain read with a loop. + fn parse_if(&mut self) -> Result { + let mut chain = Vec::new(); + let alternate = loop { + let start = self.expect_keyword(Keyword::If)?.span; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let consequent = self.parse_branch()?; + chain.push((start, test, consequent)); + if !self.at_keyword(Keyword::Else) { + break None; + } + self.bump()?; + if !self.at_keyword(Keyword::If) { + break Some(self.parse_branch()?); + } + }; + let mut alternate = alternate; + let mut last = None; + for (start, test, consequent) in chain.into_iter().rev() { + let id = self.ast.push_stmt(Stmt::If { test, consequent, alternate }, self.span_from(start)); + alternate = Some(id); + last = Some(id); + } + last.ok_or_else(|| self.unexpected()) + } + + /// The body of an iteration statement, with `break` and `continue` allowed in it. + fn parse_loop_body(&mut self) -> Result { + self.ctx.loops += 1; + self.ctx.breakables += 1; + let body = self.parse_statement(); + self.ctx.loops -= 1; + self.ctx.breakables -= 1; + body + } + + fn parse_do_while(&mut self, start: Span) -> Result { + self.bump()?; + let body = self.parse_loop_body()?; + self.expect_keyword(Keyword::While)?; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + // A `;` after `do … while (…)` may be left out even on the same line, as ECMA-262 2015 + // section 11.9.1 allows. + self.eat(Punct::Semicolon)?; + Ok(self.ast.push_stmt(Stmt::DoWhile { body, test }, self.span_from(start))) + } + + fn parse_while(&mut self, start: Span) -> Result { + self.bump()?; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::While { test, body }, self.span_from(start))) + } + + fn parse_for(&mut self, start: Span) -> Result { + self.bump()?; + self.expect(Punct::LParen)?; + let init = if self.at(Punct::Semicolon) { + None + } else if self.at_keyword(Keyword::Var) { + self.bump()?; + let mut decls = self.parse_var_decls(true)?; + if decls.len() == 1 && self.at_keyword(Keyword::In) { + let decl = decls.pop().ok_or_else(|| self.unexpected())?; + // The variable of a for-in loop takes an initializer only outside strict mode + // code (ECMA-262 2017 Annex B.3.6). + if self.ctx.strict && decl.init.is_some() { + return Err(self.error_at_token(&self.tok, "a for-in variable cannot have an initializer in strict mode code")); + } + return self.parse_for_in(ForInTarget::Var(decl), start); + } + Some(ForInit::Var(decls)) + } else { + let expr = self.parse_expression(true)?; + if self.at_keyword(Keyword::In) { + self.check_target(expr, "invalid left-hand side in for-in loop")?; + return self.parse_for_in(ForInTarget::Expr(expr), start); + } + Some(ForInit::Expr(expr)) + }; + self.expect(Punct::Semicolon)?; + let test = if self.at(Punct::Semicolon) { None } else { Some(self.parse_expression(false)?) }; + self.expect(Punct::Semicolon)?; + let update = if self.at(Punct::RParen) { None } else { Some(self.parse_expression(false)?) }; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::For { init, test, update, body }, self.span_from(start))) + } + + /// The rest of `for (target in object) body`, from `in`. + fn parse_for_in(&mut self, target: ForInTarget, start: Span) -> Result { + self.expect_keyword(Keyword::In)?; + let object = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::ForIn { target, object, body }, self.span_from(start))) + } + + /// `break` or `continue`, with an optional label on the same line, which must name a label + /// around it in the same function (an iteration statement's, for `continue`). + fn parse_jump(&mut self, is_break: bool, start: Span) -> Result { + let keyword = self.bump()?; + let label = match &self.tok.kind { + TokenKind::Ident(name) if !self.tok.newline_before => { + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + match self.ctx.labels.get(&name) { + None => return Err(self.error_at_token(&token, format!("undefined label '{name}'"))), + Some(&is_loop) if !is_break && !is_loop => { + return Err(self.error_at_token(&token, format!("label '{name}' does not name a loop, so continue cannot use it"))); + } + Some(_) => {} + } + Some(Name { name, span: token.span }) + } + _ => None, + }; + if label.is_none() { + if is_break && self.ctx.breakables == 0 { + return Err(self.error_at_token(&keyword, "break outside a loop or switch")); + } + if !is_break && self.ctx.loops == 0 { + return Err(self.error_at_token(&keyword, "continue outside a loop")); + } + } + self.semicolon()?; + let stmt = if is_break { Stmt::Break(label) } else { Stmt::Continue(label) }; + Ok(self.ast.push_stmt(stmt, self.span_from(start))) + } + + fn parse_return(&mut self, start: Span) -> Result { + let keyword = self.bump()?; + if !self.ctx.in_function { + return Err(self.error_at_token(&keyword, "return outside a function")); + } + let ends = self.at(Punct::Semicolon) || self.at(Punct::RBrace) || self.tok.kind == TokenKind::Eof || self.tok.newline_before; + let arg = if ends { None } else { Some(self.parse_expression(false)?) }; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Return(arg), self.span_from(start))) + } + + fn parse_with(&mut self, start: Span) -> Result { + if self.ctx.strict { + return Err(self.error_at_token(&self.tok, "with statements are not allowed in strict mode")); + } + self.bump()?; + self.expect(Punct::LParen)?; + let object = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_statement()?; + Ok(self.ast.push_stmt(Stmt::With { object, body }, self.span_from(start))) + } + + fn parse_switch(&mut self, start: Span) -> Result { + self.bump()?; + self.expect(Punct::LParen)?; + let discriminant = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + self.expect(Punct::LBrace)?; + self.ctx.breakables += 1; + let cases = self.parse_cases(); + self.ctx.breakables -= 1; + let cases = cases?; + Ok(self.ast.push_stmt(Stmt::Switch { discriminant, cases }, self.span_from(start))) + } + + /// A `switch` statement's clauses, up to and with its `}`; at most one `default`. + fn parse_cases(&mut self) -> Result> { + let mut cases = Vec::new(); + let mut has_default = false; + while !self.eat(Punct::RBrace)? { + let case_start = self.tok.span; + let test = if self.at_keyword(Keyword::Case) { + self.bump()?; + Some(self.parse_expression(false)?) + } else if self.at_keyword(Keyword::Default) { + if has_default { + return Err(self.error_at_token(&self.tok, "more than one default clause in switch statement")); + } + has_default = true; + self.bump()?; + None + } else { + return Err(self.unexpected()); + }; + self.expect(Punct::Colon)?; + let mut body = Vec::new(); + while !(self.at_keyword(Keyword::Case) || self.at_keyword(Keyword::Default) || self.at(Punct::RBrace)) { + if self.tok.kind == TokenKind::Eof { + return Err(self.unexpected()); + } + body.push(self.parse_statement_item()?); + } + cases.push(SwitchCase { test, body, span: self.span_from(case_start) }); + } + Ok(cases) + } + + fn parse_throw(&mut self, start: Span) -> Result { + self.bump()?; + if self.tok.newline_before { + return Err(self.error_at_token(&self.tok, "line break after throw")); + } + let arg = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Throw(arg), self.span_from(start))) + } + + fn parse_try(&mut self, start: Span) -> Result { + self.bump()?; + let block = self.parse_block()?; + let handler = if self.at_keyword(Keyword::Catch) { + self.bump()?; + self.expect(Punct::LParen)?; + let param = self.binding_identifier()?; + self.expect(Punct::RParen)?; + Some(CatchClause { param, body: self.parse_block()? }) + } else { + None + }; + let finalizer = if self.at_keyword(Keyword::Finally) { + self.bump()?; + Some(self.parse_block()?) + } else { + None + }; + if handler.is_none() && finalizer.is_none() { + return Err(self.error_at_token(&self.tok, "missing catch or finally after try")); + } + Ok(self.ast.push_stmt(Stmt::Try { block, handler, finalizer }, self.span_from(start))) + } + + /// `a: b: statement`: the labels read with a loop, each unique among the labels around it. + /// `in_list`: the labeled statement stands in a statement list, so outside strict mode code + /// its body may be a function declaration. + fn parse_labeled(&mut self, in_list: bool) -> Result { + let mut labels: Vec<(Name, Span)> = Vec::new(); + let mut chain = HashSet::new(); + while let TokenKind::Ident(name) = &self.tok.kind { + if !self.peek().is_some_and(|t| t.is_punct(Punct::Colon)) { + break; + } + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + if self.ctx.labels.contains_key(&name) || !chain.insert(name.clone()) { + return Err(self.error_at_token(&self.tok, format!("duplicate label '{name}'"))); + } + let token = self.bump()?; + self.bump()?; + labels.push((Name { name, span: token.span }, token.span)); + } + let is_loop = self.at_keyword(Keyword::For) || self.at_keyword(Keyword::While) || self.at_keyword(Keyword::Do); + self.ctx.labels.extend(chain.into_iter().map(|name| (name, is_loop))); + self.function_allowed = in_list && !self.ctx.strict; + let body = self.parse_statement(); + for (label, _) in &labels { + self.ctx.labels.remove(&label.name); + } + let mut body = body?; + for (label, label_start) in labels.into_iter().rev() { + let span = Span::new(label_start.start, self.ast.stmt_span(body).end); + body = self.ast.push_stmt(Stmt::Labeled { label, body }, span); + } + Ok(body) + } + + // ---- functions ---- + + fn parse_function_declaration(&mut self) -> Result { + let start = self.expect_keyword(Keyword::Function)?.span; + let name = self.function_name()?; + let id = self.parse_function_rest(FunctionKind::Declaration, Some(name), start)?; + Ok(self.ast.push_stmt(Stmt::FunctionDecl(id), self.span_from(start))) + } + + /// A function's name, checked as an identifier; the strict-mode checks wait for the body, + /// whose prologue may make the function strict. + fn function_name(&mut self) -> Result<(Name, Token)> { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected a function name but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + Ok((Name { name, span: token.span }, token)) + } + + /// The parameters and body of a function, from `(`. + fn parse_function_rest(&mut self, kind: FunctionKind, name: Option<(Name, Token)>, start: Span) -> Result { + self.enter()?; + let result = self.function_rest(kind, name, start); + self.leave(); + result + } + + fn function_rest(&mut self, kind: FunctionKind, name: Option<(Name, Token)>, start: Span) -> Result { + self.expect(Punct::LParen)?; + let mut params: Vec<(Name, Token)> = Vec::new(); + if !self.at(Punct::RParen) { + loop { + let (param, token) = self.function_name_or_param()?; + params.push((param, token)); + if !self.eat(Punct::Comma)? { + break; + } + } + } + let close = self.expect(Punct::RParen)?; + match kind { + FunctionKind::Getter if !params.is_empty() => return Err(self.error_at_token(&close, "a getter must not have parameters")), + FunctionKind::Setter if params.len() != 1 => return Err(self.error_at_token(&close, "a setter must have exactly one parameter")), + _ => {} + } + self.expect(Punct::LBrace)?; + let inner = Context { strict: self.ctx.strict, in_function: true, ..Context::default() }; + let outer = std::mem::replace(&mut self.ctx, inner); + let body = self.parse_body(true); + let strict = self.ctx.strict; + self.ctx = outer; + let body = body?; + self.expect(Punct::RBrace)?; + if strict { + self.check_strict_function(name.as_ref(), ¶ms)?; + } + Ok(self.ast.push_function(Function { + kind, + name: name.map(|(n, _)| n), + params: params.into_iter().map(|(n, _)| n).collect(), + body, + strict, + span: self.span_from(start), + })) + } + + /// A parameter's name. + fn function_name_or_param(&mut self) -> Result<(Name, Token)> { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected a parameter name but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + Ok((Name { name, span: token.span }, token)) + } + + /// The rules strict mode code sets for a function's name and parameters (Annex C): neither + /// `eval`, `arguments` nor a strict-mode reserved word, and no parameter name twice. + fn check_strict_function(&self, name: Option<&(Name, Token)>, params: &[(Name, Token)]) -> Result<()> { + for (n, token) in name.into_iter().chain(params) { + if is_eval_or_arguments(&n.name) { + return Err(self.error_at_token(token, format!("'{}' cannot be declared in strict mode", n.name))); + } + if strict_reserved(&n.name) { + return Err(self.error_at_token(token, format!("'{}' is a reserved word in strict mode", n.name))); + } + } + let mut seen = HashSet::new(); + for (p, token) in params { + if !seen.insert(p.name.as_str()) { + return Err(self.error_at_token(token, format!("duplicate parameter name '{}' is not allowed in strict mode", p.name))); + } + } + Ok(()) + } + + // ---- expressions ---- + + /// An expression: assignment expressions separated by commas. `no_in` leaves `in` to a + /// `for (… in …)` around it. + fn parse_expression(&mut self, no_in: bool) -> Result { + let first = self.parse_assignment(no_in)?; + if !self.at(Punct::Comma) { + return Ok(first); + } + let mut list = vec![first]; + while self.eat(Punct::Comma)? { + list.push(self.parse_assignment(no_in)?); + } + let span = self.ast.expr_span(first).to(Span::new(self.prev_end, self.prev_end)); + Ok(self.ast.push_expr(Expr::Sequence(list), span)) + } + + fn parse_assignment(&mut self, no_in: bool) -> Result { + self.enter()?; + let result = self.assignment(no_in); + self.leave(); + result + } + + /// `a = b += c`: the targets kept on a stack, so a long chain does not nest. + fn assignment(&mut self, no_in: bool) -> Result { + let mut targets: Vec<(ExprId, AssignOp)> = Vec::new(); + loop { + let expr = self.parse_conditional(no_in)?; + let Some(op) = assign_op(&self.tok) else { + let mut value = expr; + while let Some((target, op)) = targets.pop() { + let span = self.ast.expr_span(target).to(self.ast.expr_span(value)); + value = self.ast.push_expr(Expr::Assign { op, target, value }, span); + } + return Ok(value); + }; + self.check_target(expr, "invalid assignment target")?; + self.bump()?; + targets.push((expr, op)); + } + } + + /// An assignment's, `++`'s, `--`'s or `for-in`'s target: an identifier (in strict code + /// neither `eval` nor `arguments`), a property, or outside strict mode code a call (assigning + /// to its result fails at run time, as ECMA-262 2015 Annex B and web browsers have it). + fn check_target(&self, expr: ExprId, message: &str) -> Result<()> { + let inner = self.ast.unparenthesized(expr); + match self.ast.expr(inner) { + Some(Expr::Ident(name)) => { + if self.ctx.strict && is_eval_or_arguments(name) { + return Err(self.error_at_offset(self.ast.expr_span(inner).start, format!("cannot assign to '{name}' in strict mode"))); + } + Ok(()) + } + Some(Expr::Member { .. } | Expr::Index { .. }) => Ok(()), + Some(Expr::Call { .. }) if !self.ctx.strict => Ok(()), + _ => Err(self.error_at_offset(self.ast.expr_span(expr).start, message)), + } + } + + fn parse_conditional(&mut self, no_in: bool) -> Result { + let test = self.parse_binary(no_in)?; + if !self.at(Punct::Question) { + return Ok(test); + } + self.bump()?; + let consequent = self.parse_assignment(false)?; + self.expect(Punct::Colon)?; + let alternate = self.parse_assignment(no_in)?; + let span = self.ast.expr_span(test).to(self.ast.expr_span(alternate)); + Ok(self.ast.push_expr(Expr::Conditional { test, consequent, alternate }, span)) + } + + /// Binary and logical operators, read with an operator stack by precedence (all are left + /// associative). + fn parse_binary(&mut self, no_in: bool) -> Result { + let mut operands = vec![self.parse_unary()?]; + let mut ops: Vec<(BinKind, u8)> = Vec::new(); + while let Some((op, precedence)) = binary_op(&self.tok, no_in) { + while ops.last().is_some_and(|&(_, top)| top >= precedence) { + self.reduce(&mut operands, &mut ops); + } + self.bump()?; + ops.push((op, precedence)); + operands.push(self.parse_unary()?); + } + while !ops.is_empty() { + self.reduce(&mut operands, &mut ops); + } + operands.pop().ok_or_else(|| self.unexpected()) + } + + /// Joins the top two operands with the top operator. + fn reduce(&mut self, operands: &mut Vec, ops: &mut Vec<(BinKind, u8)>) { + let (Some((op, _)), Some(right), Some(left)) = (ops.pop(), operands.pop(), operands.pop()) else { + return; + }; + let span = self.ast.expr_span(left).to(self.ast.expr_span(right)); + let expr = match op { + BinKind::Binary(op) => Expr::Binary { op, left, right }, + BinKind::Logical(op) => Expr::Logical { op, left, right }, + }; + operands.push(self.ast.push_expr(expr, span)); + } + + /// Prefix operators, read with a loop and applied innermost first. + fn parse_unary(&mut self) -> Result { + let mut prefixes = Vec::new(); + while let Some(prefix) = prefix_op(&self.tok) { + prefixes.push((prefix, self.tok.span)); + self.bump()?; + } + let mut expr = self.parse_postfix()?; + for (prefix, start) in prefixes.into_iter().rev() { + let span = start.to(self.ast.expr_span(expr)); + expr = match prefix { + Prefix::Update(op) => { + self.check_target(expr, "invalid increment or decrement operand")?; + self.ast.push_expr(Expr::Update { op, prefix: true, target: expr }, span) + } + Prefix::Unary(op) => { + if op == UnaryOp::Delete && self.ctx.strict && matches!(self.ast.expr(self.ast.unparenthesized(expr)), Some(Expr::Ident(_))) { + return Err(self.error_at_offset(start.start, "delete of an unqualified identifier in strict mode")); + } + self.ast.push_expr(Expr::Unary { op, arg: expr }, span) + } + }; + } + Ok(expr) + } + + fn parse_postfix(&mut self) -> Result { + let expr = self.parse_left_hand_side()?; + let op = match &self.tok.kind { + TokenKind::Punct(Punct::PlusPlus) if !self.tok.newline_before => UpdateOp::Increment, + TokenKind::Punct(Punct::MinusMinus) if !self.tok.newline_before => UpdateOp::Decrement, + _ => return Ok(expr), + }; + self.check_target(expr, "invalid increment or decrement operand")?; + let token = self.bump()?; + let span = self.ast.expr_span(expr).to(token.span); + Ok(self.ast.push_expr(Expr::Update { op, prefix: false, target: expr }, span)) + } + + /// Member accesses, calls and `new` (section 11.2): each `new` takes the first argument list + /// after its callee, and a `new` left without one constructs with no arguments. + fn parse_left_hand_side(&mut self) -> Result { + let mut news = Vec::new(); + while self.at_keyword(Keyword::New) { + news.push(self.bump()?.span); + } + let mut expr = self.parse_primary()?; + loop { + match &self.tok.kind { + TokenKind::Punct(Punct::Dot) => { + self.bump()?; + let Some(name) = self.tok.identifier_name().map(str::to_string) else { + return Err(self.error_at_token(&self.tok, format!("expected a property name but found {}", describe(&self.tok)))); + }; + let token = self.bump()?; + let span = self.ast.expr_span(expr).to(token.span); + expr = self.ast.push_expr(Expr::Member { object: expr, property: Name { name, span: token.span } }, span); + } + TokenKind::Punct(Punct::LBracket) => { + self.bump()?; + let index = self.parse_expression(false)?; + let close = self.expect(Punct::RBracket)?; + let span = self.ast.expr_span(expr).to(close.span); + expr = self.ast.push_expr(Expr::Index { object: expr, index }, span); + } + TokenKind::Punct(Punct::LParen) => { + let args = self.parse_arguments()?; + let span = Span::new(self.ast.expr_span(expr).start, self.prev_end); + expr = match news.pop() { + Some(new) => self.ast.push_expr(Expr::New { callee: expr, args }, Span::new(new.start, self.prev_end)), + None => self.ast.push_expr(Expr::Call { callee: expr, args }, span), + }; + } + _ => break, + } + } + while let Some(new) = news.pop() { + let span = Span::new(new.start, self.ast.expr_span(expr).end); + expr = self.ast.push_expr(Expr::New { callee: expr, args: Vec::new() }, span); + } + Ok(expr) + } + + fn parse_arguments(&mut self) -> Result> { + self.expect(Punct::LParen)?; + let mut args = Vec::new(); + if !self.at(Punct::RParen) { + loop { + args.push(self.parse_assignment(false)?); + if !self.eat(Punct::Comma)? { + break; + } + } + } + self.expect(Punct::RParen)?; + Ok(args) + } + + fn parse_primary(&mut self) -> Result { + self.enter()?; + let result = self.primary(); + self.leave(); + result + } + + fn primary(&mut self) -> Result { + let start = self.tok.span; + let expr = match &self.tok.kind { + TokenKind::Keyword(Keyword::This) => Expr::This, + TokenKind::Keyword(Keyword::Null) => Expr::Null, + TokenKind::Keyword(Keyword::True) => Expr::Bool(true), + TokenKind::Keyword(Keyword::False) => Expr::Bool(false), + TokenKind::Keyword(Keyword::Function) => return self.parse_function_expression(), + TokenKind::Ident(name) => { + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + Expr::Ident(name) + } + TokenKind::Number(n) => { + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal literals are not allowed in strict mode")); + } + Expr::Number(*n) + } + TokenKind::String(s) => { + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal escape sequences are not allowed in strict mode")); + } + Expr::String(s.clone()) + } + TokenKind::Punct(Punct::Slash | Punct::SlashEq) => { + let regexp = self.lex.rescan_regexp(&self.tok)?; + self.tok = regexp; + let TokenKind::RegExp { pattern, flags } = &self.tok.kind else { + return Err(self.unexpected()); + }; + Expr::RegExp { pattern: pattern.clone(), flags: flags.clone() } + } + TokenKind::Punct(Punct::LBracket) => return self.parse_array(), + TokenKind::Punct(Punct::LBrace) => return self.parse_object(), + TokenKind::Punct(Punct::LParen) => { + self.bump()?; + let inner = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + return Ok(self.ast.push_expr(Expr::Paren(inner), self.span_from(start))); + } + _ => return Err(self.unexpected()), + }; + let token = self.bump()?; + Ok(self.ast.push_expr(expr, token.span)) + } + + fn parse_function_expression(&mut self) -> Result { + let start = self.expect_keyword(Keyword::Function)?.span; + let name = if matches!(self.tok.kind, TokenKind::Ident(_)) { Some(self.function_name()?) } else { None }; + let id = self.parse_function_rest(FunctionKind::Expression, name, start)?; + Ok(self.ast.push_expr(Expr::Function(id), self.span_from(start))) + } + + /// `[a, , b]`: elements, a comma with nothing before it leaving a hole. + fn parse_array(&mut self) -> Result { + let start = self.expect(Punct::LBracket)?.span; + let mut elements = Vec::new(); + loop { + if self.eat(Punct::RBracket)? { + break; + } + if self.eat(Punct::Comma)? { + elements.push(None); + continue; + } + elements.push(Some(self.parse_assignment(false)?)); + if !self.at(Punct::RBracket) { + self.expect(Punct::Comma)?; + } + } + Ok(self.ast.push_expr(Expr::Array(elements), self.span_from(start))) + } + + /// `{a: 1, "b": 2, 3: c, get d() {…}, set d(v) {…}}`. A name may repeat, a later definition + /// replacing an earlier one (ECMA-262 2015 section 12.2.6.1). + fn parse_object(&mut self) -> Result { + let start = self.expect(Punct::LBrace)?.span; + let mut properties = Vec::new(); + while !self.eat(Punct::RBrace)? { + let property = self.parse_property()?; + properties.push(property); + if !self.at(Punct::RBrace) { + self.expect(Punct::Comma)?; + } + } + Ok(self.ast.push_expr(Expr::Object(properties), self.span_from(start))) + } + + fn parse_property(&mut self) -> Result { + let start = self.tok.span; + // `get` and `set` start an accessor only when written without escapes. + let accessor = match self.tok.ident().filter(|_| !self.tok.escaped) { + Some("get") => Some(FunctionKind::Getter), + Some("set") => Some(FunctionKind::Setter), + _ => None, + }; + if let Some(kind) = accessor { + let word = self.bump()?; + if property_key(&self.tok).is_some() { + let (key, _) = self.parse_property_key()?; + let function = self.parse_function_rest(kind, None, start)?; + let value = if kind == FunctionKind::Getter { PropertyValue::Get(function) } else { PropertyValue::Set(function) }; + return Ok(Property { key, value, span: self.span_from(start) }); + } + let key = PropertyKey::Ident(word.ident().unwrap_or_default().to_string()); + return self.property_value(key, start); + } + let (key, _) = self.parse_property_key()?; + self.property_value(key, start) + } + + /// `: value` after a data property's name. + fn property_value(&mut self, key: PropertyKey, start: Span) -> Result { + self.expect(Punct::Colon)?; + let value = self.parse_assignment(false)?; + Ok(Property { key, value: PropertyValue::Init(value), span: self.span_from(start) }) + } + + fn parse_property_key(&mut self) -> Result<(PropertyKey, Token)> { + let Some(key) = property_key(&self.tok) else { + return Err(self.error_at_token(&self.tok, format!("expected a property name but found {}", describe(&self.tok)))); + }; + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal literals and escape sequences are not allowed in strict mode")); + } + let token = self.bump()?; + Ok((key, token)) + } +} + +/// The key a token gives as a property name: an identifier name, a string or a number. +fn property_key(token: &Token) -> Option { + match &token.kind { + TokenKind::String(s) => Some(PropertyKey::String(s.clone())), + TokenKind::Number(n) => Some(PropertyKey::Number(*n)), + _ => token.identifier_name().map(|n| PropertyKey::Ident(n.to_string())), + } +} + +fn is_eval_or_arguments(name: &str) -> bool { + name == "eval" || name == "arguments" +} + +fn assign_op(token: &Token) -> Option { + let TokenKind::Punct(p) = &token.kind else { return None }; + Some(match p { + Punct::Eq => None, + Punct::PlusEq => Some(BinaryOp::Add), + Punct::MinusEq => Some(BinaryOp::Sub), + Punct::StarEq => Some(BinaryOp::Mul), + Punct::SlashEq => Some(BinaryOp::Div), + Punct::PercentEq => Some(BinaryOp::Mod), + Punct::ShlEq => Some(BinaryOp::Shl), + Punct::ShrEq => Some(BinaryOp::Shr), + Punct::UShrEq => Some(BinaryOp::UShr), + Punct::AmpEq => Some(BinaryOp::BitAnd), + Punct::PipeEq => Some(BinaryOp::BitOr), + Punct::CaretEq => Some(BinaryOp::BitXor), + _ => return None, + }) +} + +/// A binary operator and its precedence (higher binds tighter); `in` is left out under `no_in`. +fn binary_op(token: &Token, no_in: bool) -> Option<(BinKind, u8)> { + use BinKind::{Binary as B, Logical as L}; + match &token.kind { + TokenKind::Keyword(Keyword::Instanceof) => Some((B(BinaryOp::Instanceof), 7)), + TokenKind::Keyword(Keyword::In) if !no_in => Some((B(BinaryOp::In), 7)), + TokenKind::Punct(p) => Some(match p { + Punct::PipePipe => (L(LogicalOp::Or), 1), + Punct::AmpAmp => (L(LogicalOp::And), 2), + Punct::Pipe => (B(BinaryOp::BitOr), 3), + Punct::Caret => (B(BinaryOp::BitXor), 4), + Punct::Amp => (B(BinaryOp::BitAnd), 5), + Punct::EqEq => (B(BinaryOp::Eq), 6), + Punct::NotEq => (B(BinaryOp::NotEq), 6), + Punct::EqEqEq => (B(BinaryOp::StrictEq), 6), + Punct::NotEqEq => (B(BinaryOp::StrictNotEq), 6), + Punct::Lt => (B(BinaryOp::Lt), 7), + Punct::Gt => (B(BinaryOp::Gt), 7), + Punct::LtEq => (B(BinaryOp::LtEq), 7), + Punct::GtEq => (B(BinaryOp::GtEq), 7), + Punct::Shl => (B(BinaryOp::Shl), 8), + Punct::Shr => (B(BinaryOp::Shr), 8), + Punct::UShr => (B(BinaryOp::UShr), 8), + Punct::Plus => (B(BinaryOp::Add), 9), + Punct::Minus => (B(BinaryOp::Sub), 9), + Punct::Star => (B(BinaryOp::Mul), 10), + Punct::Slash => (B(BinaryOp::Div), 10), + Punct::Percent => (B(BinaryOp::Mod), 10), + _ => return None, + }), + _ => None, + } +} + +fn prefix_op(token: &Token) -> Option { + Some(match &token.kind { + TokenKind::Keyword(Keyword::Delete) => Prefix::Unary(UnaryOp::Delete), + TokenKind::Keyword(Keyword::Void) => Prefix::Unary(UnaryOp::Void), + TokenKind::Keyword(Keyword::Typeof) => Prefix::Unary(UnaryOp::Typeof), + TokenKind::Punct(Punct::Plus) => Prefix::Unary(UnaryOp::Plus), + TokenKind::Punct(Punct::Minus) => Prefix::Unary(UnaryOp::Minus), + TokenKind::Punct(Punct::Tilde) => Prefix::Unary(UnaryOp::BitNot), + TokenKind::Punct(Punct::Bang) => Prefix::Unary(UnaryOp::Not), + TokenKind::Punct(Punct::PlusPlus) => Prefix::Update(UpdateOp::Increment), + TokenKind::Punct(Punct::MinusMinus) => Prefix::Update(UpdateOp::Decrement), + _ => return None, + }) +} + +/// A token as an error message names it. +fn describe(token: &Token) -> String { + match &token.kind { + TokenKind::Eof => "end of input".into(), + TokenKind::Ident(name) => format!("identifier '{name}'"), + TokenKind::Keyword(k) => format!("token '{}'", k.as_str()), + TokenKind::Punct(p) => format!("token '{}'", p.as_str()), + TokenKind::Number(_) => "number".into(), + TokenKind::String(_) => "string".into(), + TokenKind::RegExp { .. } => "regular expression".into(), + TokenKind::Directive(_) => "preprocessor line".into(), + } +} diff --git a/crates/js/src/regexp.rs b/crates/js/src/regexp.rs new file mode 100644 index 000000000..cda4f5307 --- /dev/null +++ b/crates/js/src/regexp.rs @@ -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, 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 { + 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> = 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, 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 { + (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), + }) +} diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs new file mode 100644 index 000000000..fb357e1bf --- /dev/null +++ b/crates/js/src/tests.rs @@ -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 { + 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 { + 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"), + ("/(?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 = 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::())); + 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::>().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::>()) + .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::(), 0..120)) { + let _ = crate::parse_utf16(&units, &Options::default()); + } +} diff --git a/crates/js/src/token.rs b/crates/js/src/token.rs new file mode 100644 index 000000000..53bbb35af --- /dev/null +++ b/crates/js/src/token.rs @@ -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 { + 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 +/// (). +/// 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 { + 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, + /// 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), + /// A regular expression literal: its pattern and flags as written. + RegExp { + pattern: Vec, + 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, + } + } +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs new file mode 100644 index 000000000..c32045507 --- /dev/null +++ b/crates/js/tests/test262.rs @@ -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, + flags: Vec, + negative_phase: Option, + /// The test names the ES5 section it was written for (`es5id:`). + es5: bool, +} + +fn suite_dir() -> Option { + 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 = 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 { + 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>) -> Vec { + 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() + ); +} diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 1fb4bd77a..5e4117a18 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -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 } diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs new file mode 100644 index 000000000..f47d8e69b --- /dev/null +++ b/crates/plugins/src/host.rs @@ -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 { + 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, + /// 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 { + let mut linker = Linker::::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> { + 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); + } +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 061ef506c..5e5da17b9 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -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}; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 18ffeb335..36b4b78c4 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -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>, /// 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 { + 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( diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs new file mode 100644 index 000000000..083c78a04 --- /dev/null +++ b/crates/plugins/src/permissions.rs @@ -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 { + 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> { + if names.len() > MAX_PERMISSIONS { + return Err(Error::Manifest(format!("at most {MAX_PERMISSIONS} `permissions`"))); + } + let mut out: Vec = 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, +} + +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::>(); + 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])); + } +} diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index caf63eb54..0302ab6be 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -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>> = RwLock::new(BTreeMap::new()); static REVISION: AtomicU64 = AtomicU64::new(0); +/// What the user approved, by plug-in id. +static GRANTS: RwLock> = 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 { + GRANTS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() +} + +/// Every grant, sorted by id. +pub fn grants() -> Vec { + 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 { @@ -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 { - 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 { +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 { let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?; let mut files: Vec = 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())), } diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 3a72b70d7..e6c289b8f 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -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, 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, + store: Store, 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 { 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 { + /// 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 { 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::::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(&self, live: &mut Live, f: &TypedFunc, params: P, fuel: u64, deadline: &Deadline) -> Result { 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> { - // 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> { + /// [`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>, 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> { 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> { let alloc: TypedFunc = live.func("vc_alloc")?; let run: TypedFunc = live.func("vc_run")?; let place = |live: &mut Live, bytes: &[u8]| -> Result { - 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}"))); } diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index 0201e1a50..af041414a 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -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. diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index 5fbb694d5..f5b78d224 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -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 { + 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> { + 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] diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 2ad5170d4..490703aa2 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -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(), diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index c98af03d3..2ca5b5978 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -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()); +} diff --git a/crates/render/tests/envelope_ink.rs b/crates/render/tests/envelope_ink.rs new file mode 100644 index 000000000..7622143f5 --- /dev/null +++ b/crates/render/tests/envelope_ink.rs @@ -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:?}"); +} diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index ffb9572e3..e29334627 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -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"); } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 9f05c22b0..1ff72bdd2 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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 diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 895e3a1a7..f55750e95 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -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 { + let db = FontDb::global(); + let laid = layout(db, t); + let bars = decorations(&laid, db, t).into_iter().map(|(_, bar)| bar); + let union = |acc: Option, 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, 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; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 1ea6e5a94..d7bc1d444 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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 { + 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(); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 79d3d8f0d..a74582972 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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(), diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 982972770..985f98c34 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -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); } diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 1980661b8..63b4ef9c9 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -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). diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index e0bd9f029..49baf494b 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -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); diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index fa2be21c0..adbf1223f 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -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; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4193f8d6a..a7766dd8d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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, diff --git a/crates/ui-egui/src/dialogs/objects_on_path_options.rs b/crates/ui-egui/src/dialogs/objects_on_path_options.rs index 06aae7f8e..8a02cd37c 100644 --- a/crates/ui-egui/src/dialogs/objects_on_path_options.rs +++ b/crates/ui-egui/src/dialogs/objects_on_path_options.rs @@ -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)); } }); diff --git a/crates/ui-egui/src/dialogs/placement_options.rs b/crates/ui-egui/src/dialogs/placement_options.rs index 55849558f..dbe8ff827 100644 --- a/crates/ui-egui/src/dialogs/placement_options.rs +++ b/crates/ui-egui/src/dialogs/placement_options.rs @@ -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)); diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 6b69c9b3f..523c42d06 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -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::(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:"); diff --git a/crates/ui-egui/src/dialogs/relink.rs b/crates/ui-egui/src/dialogs/relink.rs new file mode 100644 index 000000000..ceca12c04 --- /dev/null +++ b/crates/ui-egui/src/dialogs/relink.rs @@ -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 { + 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)> = 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 = 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 = 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 { + 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 { + 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 { + 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 = 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 { + 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); + } +} diff --git a/crates/ui-egui/src/dialogs/symbol_options.rs b/crates/ui-egui/src/dialogs/symbol_options.rs index f388cb4ec..79e26764d 100644 --- a/crates/ui-egui/src/dialogs/symbol_options.rs +++ b/crates/ui-egui/src/dialogs/symbol_options.rs @@ -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(); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 35bd659fc..51959c320 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -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(), diff --git a/crates/ui-egui/src/export_progress.rs b/crates/ui-egui/src/export_progress.rs index 02d1ab7ff..3d392c3a3 100644 --- a/crates/ui-egui/src/export_progress.rs +++ b/crates/ui-egui/src/export_progress.rs @@ -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()); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index df2b9b4d8..e9a219bd5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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 Нов набор diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index ac0a9cc5f..ca52bf517 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 22ce8e4eb..ea246b35c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 12421cd4b..7e51de244 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8ab0e613c..84134c893 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7f2fc4ca8..7d0e48fb8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8ad6f40d3..be001e2fb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1895,6 +1895,7 @@ font-technology Variable Μεταβλητές Rotate Περιστροφή Rotate View Tool Εργαλείο περιστροφής προβολής (Rotate View Tool) Trackpad Gesture to Rotate View Περιστροφή προβολής με χειρονομία στην επιφάνεια αφής + Hold a Tool Shortcut to Use the Tool Temporarily Προσωρινή χρήση εργαλείου κρατώντας πατημένη τη συντόμευσή του Rotate… Περιστροφή… Rotation Περιστροφή Rows: Γραμμές: @@ -3274,6 +3275,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… Εύρεση συνδεδεμένων αρχείων σε φάκελο… @@ -5764,6 +5773,33 @@ axis Y Axis Άξονας Y @msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy` @msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `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}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): εγκαταστήστε το με `allowHeavy: true` μόλις συμφωνήσει ο χρήστης +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα; + 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 Εμφάνιση της προόδου, αναγνώριση της ακύρωσης και εγγραφή γραμμών καταγραφής +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `progress` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} η λειτουργική μονάδα εισάγει το `env.{name}` με άλλον τύπο από αυτόν του κεντρικού υπολογιστή: ο τύπος του είναι {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` η λειτουργική μονάδα εισάγει το `env.{name}`, το οποίο ένα πρόσθετο μπορεί να χρησιμοποιεί μόνο στο επίπεδο API προσθέτων {PROGRESS_API} (`api` {PROGRESS_API}) και με το δικαίωμα `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 η λειτουργική μονάδα εισάγει το `{from}.{name}`, το οποίο δεν προσφέρει ο κεντρικός υπολογιστής: ένα πρόσθετο μπορεί να εισάγει μόνο τα `env.vc_progress`, `env.vc_cancelled` και `env.vc_log` (επίπεδο API προσθέτων 2) και κανένα WASI, αρχεία ή δίκτυο +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} το `api` είναι {_1}· αυτός ο κεντρικός υπολογιστής υποστηρίζει τα επίπεδα API προσθέτων 1 έως {API_LEVEL} +@msg `grant` must be a list of permission names το `grant` πρέπει να είναι λίστα ονομάτων δικαιωμάτων +@msg `grant` names an unknown permission `{shown}` το `grant` αναφέρει άγνωστο δικαίωμα `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` ένα πρόσθετο τύπου `{_1}` χρειάζεται το δικαίωμα `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` το πολύ {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` το δικαίωμα `{shown}` δεν ισχύει για πρόσθετο τύπου `{_1}` +@msg permission `{shown}` is listed twice το δικαίωμα `{shown}` αναφέρεται δύο φορές +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί +@msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed στο «{name}» δεν παραχωρήθηκε το `{p}`, που χρειάζεται η έξοδός του: δεν άλλαξε τίποτα +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα # Actions panel: action sets (#3712). New Set… Νέο σύνολο… New Set Νέο σύνολο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9114fa1d8..930ecf090 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2708,6 +2708,14 @@ digits Malayalam Malayálam Relink Volver a vincular Relink Linked Files to the Document Volver a vincular los archivos vinculados al documento Relink to Folder… Volver a vincular a carpeta… + Each Link Separately Cada vínculo por separado + Selected Instances Instancias seleccionadas + All Instances Todas las instancias + Missing Links from This Folder Vínculos que faltan de esta carpeta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} no es 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 y paths van juntos: paths enumera entradas con ids y path, en lugar de ids, path y folder +@msg paths entry {i} needs path and ids (object ids no other entry has) la entrada {i} de paths necesita path e ids (id. de objeto que ninguna otra entrada tenga) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder toma path: en la carpeta de ese archivo se buscan los demás vínculos que faltan Relink… Volver a vincular… Find Linked Files Buscar archivos vinculados Find Linked Files in Folder… Buscar archivos vinculados en una carpeta… @@ -2825,6 +2833,7 @@ digits Malayalam Malayálam Rotate Tool Herramienta Rotar Rotate View Tool Herramienta Rotar vista Trackpad Gesture to Rotate View Rotar vista con un gesto del trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Mantener pulsado el atajo de una herramienta para usarla temporalmente Rotate… Rotar… Rotation Rotación Roughen… Hacer áspero… @@ -5754,6 +5763,33 @@ axis Y Axis Eje Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `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}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): instálalo con `allowHeavy: true` cuando el usuario lo acepte +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos? + ID: {id} ID: {id} + Version: {version} Versión: {version} + File SHA-256: {hash} SHA-256 del archivo: {hash} + Read the objects it is given Leer los objetos que recibe + Change, add and delete objects in the document Cambiar, añadir y eliminar objetos del documento + Keep data of its own with the document Guardar datos propios con el documento + Change how the objects its effect is applied to are drawn Cambiar cómo se dibujan los objetos a los que se aplica su efecto + Write the document as a file in its own format Escribir el documento como un archivo en su propio formato + Show its progress, see Cancel and write log lines Mostrar su progreso, detectar la cancelación y escribir líneas de registro +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `progress` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} el módulo importa `env.{name}` con un tipo distinto del del host: el del host es {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ódulo importa `env.{name}`, que un plugin solo puede usar en el nivel de API de plugins {PROGRESS_API} (`api` {PROGRESS_API}) y con el permiso `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ódulo importa `{from}.{name}`, que el host no ofrece: un plugin solo puede importar `env.vc_progress`, `env.vc_cancelled` y `env.vc_log` (nivel de API de plugins 2), y ningún WASI, archivo ni red +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` es {_1}; este host admite los niveles de API de plugins de 1 a {API_LEVEL} +@msg `grant` must be a list of permission names `grant` debe ser una lista de nombres de permisos +@msg `grant` names an unknown permission `{shown}` `grant` nombra un permiso desconocido `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un plugin de tipo `{_1}` necesita el permiso `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` como máximo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permiso `{shown}` no se aplica a un plugin de tipo `{_1}` +@msg permission `{shown}` is listed twice el permiso `{shown}` aparece dos veces +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede +@msg unknown permission `{shown}` permiso desconocido `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” no se le concedió `{p}`, que su resultado necesita: no se cambió nada +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el módulo no tiene el SHA-256 que indica `hash`: no se instaló nada # Actions panel: action sets (#3712). New Set… Nuevo conjunto… New Set Nuevo conjunto diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 433a38e89..99594975d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1295,6 +1295,7 @@ lighting Light Valo Rotate Tool Kiertotyökalu Rotate View Tool Näkymän kiertotyökalu Trackpad Gesture to Rotate View Kierrä näkymää ohjauslevyn eleellä + Hold a Tool Shortcut to Use the Tool Temporarily Käytä työkalua tilapäisesti pitämällä sen pikanäppäintä painettuna Rotate View Kierrä näkymää Rounded Rectangle Tool Pyöristetyn suorakulmion työkalu Scale Tool Skaalaustyökalu @@ -2053,6 +2054,14 @@ digits Malayalam Malajalam Location Sijainti No images Ei kuvia Relink to Folder… Linkitä uudelleen kansioon… + Each Link Separately Jokainen linkki erikseen + Selected Instances Valitut esiintymät + All Instances Kaikki esiintymät + Missing Links from This Folder Puuttuvat linkit tästä kansiosta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} ei ole 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 ja paths kuuluvat yhteen: paths luettelee merkinnät, joissa on ids ja path, ids:n, path:n ja folder:n sijaan +@msg paths entry {i} needs path and ids (object ids no other entry has) paths-merkintä {i} tarvitsee path- ja ids-arvot (objektitunnukset, joita millään muulla merkinnällä ei ole) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder ottaa path-arvon: muita puuttuvia linkkejä haetaan tämän tiedoston kansiosta Show Missing Näytä puuttuvat Sort by Status Lajittele tilan mukaan Clear Overrides Tyhjennä ohitukset @@ -5843,6 +5852,33 @@ axis Y Axis Y-akseli @msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy` @msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois @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}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): asenna se asetuksella `allowHeavy: true`, kun käyttäjä hyväksyy +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia? + ID: {id} Tunnus: {id} + Version: {version} Versio: {version} + File SHA-256: {hash} Tiedoston SHA-256: {hash} + Read the objects it is given Lukea sille annetut objektit + Change, add and delete objects in the document Muuttaa, lisätä ja poistaa asiakirjan objekteja + Keep data of its own with the document Tallentaa omia tietojaan asiakirjaan + Change how the objects its effect is applied to are drawn Muuttaa sitä, miten objektit, joihin sen tehostetta on käytetty, piirretään + Write the document as a file in its own format Kirjoittaa asiakirjan tiedostoksi omassa muodossaan + Show its progress, see Cancel and write log lines Näyttää edistymisensä, havaita peruutuksen ja kirjoittaa lokirivejä +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `progress` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} moduuli tuo kohteen `env.{name}` eri tyyppisenä kuin isäntä: isännän tyyppi on {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` moduuli tuo kohteen `env.{name}`, jota laajennus saa käyttää vain laajennus-API:n tasolla {PROGRESS_API} (`api` {PROGRESS_API}) ja käyttöoikeudella `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 moduuli tuo kohteen `{from}.{name}`, jota isäntä ei tarjoa: laajennus saa tuoda vain kohteet `env.vc_progress`, `env.vc_cancelled` ja `env.vc_log` (laajennus-API:n taso 2) eikä mitään WASIa, tiedostoja tai verkkoa +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` on {_1}; tämä isäntä tukee laajennus-API:n tasoja 1 ja {API_LEVEL} välillä +@msg `grant` must be a list of permission names `grant` on oltava luettelo käyttöoikeuksien nimistä +@msg `grant` names an unknown permission `{shown}` `grant` nimeää tuntemattoman käyttöoikeuden `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` lajin `{_1}` laajennus tarvitsee käyttöoikeuden `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` enintään {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` käyttöoikeus `{shown}` ei koske lajin `{_1}` laajennusta +@msg permission `{shown}` is listed twice käyttöoikeus `{shown}` on lueteltu kahdesti +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä +@msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” ei saanut käyttöoikeutta `{p}`, jota sen tulos tarvitsee: mitään ei muutettu +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduulilla ei ole `hash`-kentän SHA-256-arvoa: mitään ei asennettu # Actions panel: action sets (#3712). New Set… Uusi sarja… New Set Uusi sarja diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index dd24d124e..1aebc348d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2700,6 +2700,14 @@ digits Malayalam Malayalam Relink Lier à nouveau Relink Linked Files to the Document Lier à nouveau les fichiers liés au document Relink to Folder… Lier à nouveau à un dossier… + Each Link Separately Chaque lien séparément + Selected Instances Occurrences sélectionnées + All Instances Toutes les occurrences + Missing Links from This Folder Liens manquants de ce dossier +@msg scope {v} is not each|selected|all|missingInFolder scope {v} n’est pas 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 et paths vont ensemble : paths liste des entrées avec ids et path, à la place de ids, path et folder +@msg paths entry {i} needs path and ids (object ids no other entry has) l’entrée {i} de paths nécessite path et ids (des identifiants d’objet qu’aucune autre entrée n’a) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder prend path : les autres liens manquants sont cherchés dans le dossier de ce fichier Relink… Lier à nouveau… Find Linked Files Rechercher des fichiers liés Find Linked Files in Folder… Rechercher des fichiers liés dans un dossier… @@ -2817,6 +2825,7 @@ digits Malayalam Malayalam Rotate Tool Outil Rotation Rotate View Tool Outil Faire pivoter l’affichage Trackpad Gesture to Rotate View Faire pivoter l’affichage avec un geste du trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Maintenir le raccourci d’un outil pour l’utiliser temporairement Rotate… Rotation… Rotation Rotation Roughen… Rugosité… @@ -5808,6 +5817,33 @@ axis Y Axis Axe Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy` @msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `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} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? + ID: {id} ID : {id} + Version: {version} Version : {version} + File SHA-256: {hash} SHA-256 du fichier : {hash} + Read the objects it is given Lire les objets qui lui sont transmis + Change, add and delete objects in the document Modifier, ajouter et supprimer des objets du document + Keep data of its own with the document Conserver ses propres données avec le document + Change how the objects its effect is applied to are drawn Modifier le rendu des objets auxquels son effet est appliqué + Write the document as a file in its own format Écrire le document dans un fichier à son propre format + Show its progress, see Cancel and write log lines Afficher sa progression, détecter l’annulation et écrire des lignes de journal +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `progress` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} le module importe `env.{name}` avec un autre type que celui de l’hôte : celui de l’hôte est {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` le module importe `env.{name}`, qu’un module externe ne peut utiliser qu’au niveau d’API de module externe {PROGRESS_API} (`api` {PROGRESS_API}) et avec l’autorisation `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 le module importe `{from}.{name}`, que l’hôte ne propose pas : un module externe ne peut importer que `env.vc_progress`, `env.vc_cancelled` et `env.vc_log` (niveau d’API de module externe 2), et aucun WASI, fichier ni réseau +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL} +@msg `grant` must be a list of permission names `grant` doit être une liste de noms d’autorisations +@msg `grant` names an unknown permission `{shown}` `grant` nomme une autorisation inconnue `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un module externe de type `{_1}` a besoin de l’autorisation `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` au plus {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorisation `{shown}` ne s’applique pas à un module externe de type `{_1}` +@msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas +@msg unknown permission `{shown}` autorisation inconnue `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé # Actions panel: action sets (#3712). New Set… Nouveau jeu… New Set Nouveau jeu diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b2a596b6f..0f212f4fd 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3624,6 +3624,14 @@ 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) paths की एंट्री {i} को path और ids चाहिए (ऐसे ऑब्जेक्ट ids जो किसी दूसरी एंट्री में न हों) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder, path लेता है: उस फ़ाइल के फ़ोल्डर में बाकी मिसिंग लिंक खोजे जाते हैं Relink… दोबारा लिंक करें… Reload Plug-ins प्लग-इन फिर से लोड करें Remember Import Matches याद रखें आयात मैच @@ -4833,6 +4841,7 @@ Tracing the colors… रंग अनुरेखण… Tracking ट्रैकिंग Trackpad Gesture to Rotate View दृश्य घुमाने के लिए ट्रैकपैड जेस्चर + Hold a Tool Shortcut to Use the Tool Temporarily किसी टूल का शॉर्टकट दबाए रखकर उसे अस्थायी रूप से उपयोग करें Trademark Symbol ट्रेडमार्क सिंबल Transform रूपांतरण करें Transform Again फिर से बदलना @@ -5205,6 +5214,33 @@ @id pathfinder.add मिलाएँ @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}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): उपयोगकर्ता की सहमति मिलने पर इसे `allowHeavy: true` से इंस्टॉल करें @msg "{0}" is not a colour: write it as #rrggbb "{0}" एक रंग नहीं है: इसे #rrggbb के रूप में लिखें +# 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 अपनी प्रगति दिखाना, रद्द करने का अनुरोध देखना और लॉग पंक्तियाँ लिखना +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `progress` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें +@msg the module imports `env.{name}` with another type than the host's: it is {signature} मॉड्यूल `env.{name}` को होस्ट से अलग प्रकार के साथ आयात करता है: होस्ट का प्रकार {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` मॉड्यूल `env.{name}` आयात करता है, जिसे प्लग-इन केवल प्लग-इन API स्तर {PROGRESS_API} (`api` {PROGRESS_API}) पर और अनुमति `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 मॉड्यूल `{from}.{name}` आयात करता है, जिसे होस्ट उपलब्ध नहीं कराता: प्लग-इन केवल `env.vc_progress`, `env.vc_cancelled` और `env.vc_log` (प्लग-इन API स्तर 2) आयात कर सकता है, और कोई WASI, फ़ाइल या नेटवर्क नहीं +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` {_1} है; यह होस्ट प्लग-इन API स्तर 1 से {API_LEVEL} तक का समर्थन करता है +@msg `grant` must be a list of permission names `grant` अनुमति नामों की सूची होनी चाहिए +@msg `grant` names an unknown permission `{shown}` `grant` एक अज्ञात अनुमति `{shown}` बताता है +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` प्रकार के प्लग-इन को `{_2}` अनुमति चाहिए +@msg at most {MAX_PERMISSIONS} `permissions` अधिकतम {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` अनुमति `{shown}` `{_1}` प्रकार के प्लग-इन पर लागू नहीं होती +@msg permission `{shown}` is listed twice अनुमति `{shown}` दो बार सूचीबद्ध है +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता +@msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” को `{p}` नहीं दी गई, जो उसके आउटपुट को चाहिए: कुछ नहीं बदला +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ @msg “{name}” is not a file "{name}" एक फ़ाइल नहीं है @msg ({note}) ({more}) ({note}) ({more}) @msg (fonts not copied: {fonts}) (फ़ॉन्ट कॉपी नहीं हुए: {fonts}) @@ -7376,7 +7412,6 @@ @msg the library holds {MAX_GROUPS} groups already लाइब्रेरी में पहले से ही {MAX_GROUPS} समूह हैं। @msg the library holds {MAX_ITEMS} items of this kind already लाइब्रेरी पहले से ही इस तरह के {MAX_ITEMS} आइटम रखता है @msg the library item is damaged लाइब्रेरी आइटम क्षतिग्रस्त है -@msg the module imports `{_1}.{_2}`, but plug-ins get no host imports (no WASI, files or network) मॉड्यूल आयात `{_1}.{_2}`, लेकिन प्लग-इन को कोई होस्ट आयात नहीं मिलता है (कोई WASI, फ़ाइल या नेटवर्क नहीं) @msg the module must export its linear memory as `memory` मॉड्यूल को अपनी रैखिक मेमोरी `memory` के रूप में एक्सपोर्ट करनी चाहिए @msg the name is empty नाम खाली है @msg the object already has an opacity mask ऑब्जेक्ट पहले से ही एक अपारदर्शिता मास्क है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4c79d9415..a400c495f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3231,6 +3231,14 @@ mark-style Japanese Japán Relink Újracsatolás Relink Linked Files to the Document Csatolt fájlok újracsatolása a dokumentumhoz Relink to Folder… Újracsatolás mappából… + Each Link Separately Minden csatolás külön + Selected Instances Kijelölt példányok + All Instances Összes példány + Missing Links from This Folder Hiányzó csatolások ebből a mappából +@msg scope {v} is not each|selected|all|missingInFolder a scope {v} nem 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 a scope each és a paths együtt jár: a paths ids és path elemű bejegyzéseket sorol fel az ids, path és folder helyett +@msg paths entry {i} needs path and ids (object ids no other entry has) a paths {i} bejegyzéséhez path és ids kell (olyan objektumazonosítók, amelyek más bejegyzésben nincsenek) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links a scope missingInFolder path értéket vár: a többi hiányzó csatolást a fájl mappájában keresi Relink… Újracsatolás… Find Linked Files Csatolt fájlok keresése Find Linked Files in Folder… Csatolt fájlok keresése mappában… @@ -3392,6 +3400,7 @@ mark-style Japanese Japán Rotate View Nézet elforgatása Rotate View Tool Nézet elforgatása Trackpad Gesture to Rotate View Nézet elforgatása kézmozdulattal az érintőpadon + Hold a Tool Shortcut to Use the Tool Temporarily Eszköz ideiglenes használata a billentyűparancsa nyomva tartásával Rotate… Elforgatás… Rotation Elforgatás Rotation relative to: Elforgatás viszonyítási alapja: @@ -5796,6 +5805,33 @@ axis Y Axis Y tengely @msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy` @msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot @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 a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): telepítsd az `allowHeavy: true` megadásával, miután a felhasználó beleegyezett +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Telepíted a(z) „{name}” bővítményt, amely ezeket az engedélyeket kéri? + ID: {id} Azonosító: {id} + Version: {version} Verzió: {version} + File SHA-256: {hash} A fájl SHA-256 értéke: {hash} + Read the objects it is given Olvashatja a kapott objektumokat + Change, add and delete objects in the document Módosíthat, hozzáadhat és törölhet objektumokat a dokumentumban + Keep data of its own with the document Saját adatokat tárolhat a dokumentummal + Change how the objects its effect is applied to are drawn Módosíthatja, hogyan rajzolódnak ki az objektumok, amelyekre az effektusa alkalmazva van + Write the document as a file in its own format Saját formátumú fájlba írhatja a dokumentumot + Show its progress, see Cancel and write log lines Megjelenítheti az előrehaladását, észlelheti a megszakítást és naplósorokat írhat +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a `progress` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket +@msg the module imports `env.{name}` with another type than the host's: it is {signature} a modul az `env.{name}` elemet a gazdaprogramétól eltérő típussal importálja: a gazdaprogram típusa {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` a modul importálja az `env.{name}` elemet, amelyet bővítmény csak {PROGRESS_API}. szintű bővítmény-API-val (`api` {PROGRESS_API}) és a `progress` engedéllyel használhat +@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 a modul importálja a(z) `{from}.{name}` elemet, amelyet a gazdaprogram nem kínál: bővítmény csak az `env.vc_progress`, `env.vc_cancelled` és `env.vc_log` elemet importálhatja (2. szintű bővítmény-API), WASI-t, fájlokat és hálózatot nem +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} az `api` értéke {_1}; ez a gazdaprogram az 1 és {API_LEVEL} közötti bővítmény-API-szinteket támogatja +@msg `grant` must be a list of permission names a `grant` értékének engedélynevek listájának kell lennie +@msg `grant` names an unknown permission `{shown}` a `grant` ismeretlen engedélyt nevez meg: `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` egy `{_1}` fajtájú bővítménynek szüksége van a(z) `{_2}` engedélyre +@msg at most {MAX_PERMISSIONS} `permissions` legfeljebb {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a(z) `{shown}` engedély nem vonatkozik `{_1}` fajtájú bővítményre +@msg permission `{shown}` is listed twice a(z) `{shown}` engedély kétszer szerepel +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg +@msg unknown permission `{shown}` ismeretlen engedély: `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a(z) „{name}” nem kapta meg a(z) `{p}` engedélyt, amelyre a kimenetének szüksége van: semmi sem változott +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed a modul SHA-256 értéke nem az, amit a `hash` megad: semmi sem lett telepítve # Actions panel: action sets (#3712). New Set… Új készlet… New Set Új készlet diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f4d529a6f..0134649ea 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1232,6 +1232,7 @@ lighting Light Cahaya Rotate Tool Alat putar Rotate View Tool Alat putar tampilan Trackpad Gesture to Rotate View Putar tampilan dengan gestur trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Tahan pintasan alat untuk menggunakan alat itu sementara Rotate View Putar tampilan Rounded Rectangle Tool Alat persegi panjang bulat Scale Tool Alat skala @@ -1898,6 +1899,14 @@ digits Malayalam Malayalam Location Lokasi No images Tidak ada gambar Relink to Folder… Tautkan ulang ke folder… + Each Link Separately Setiap tautan terpisah + Selected Instances Instans yang dipilih + All Instances Semua instans + Missing Links from This Folder Tautan hilang dari folder ini +@msg scope {v} is not each|selected|all|missingInFolder scope {v} bukan 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 dan paths digunakan bersama: paths mencantumkan entri berisi ids dan path, sebagai ganti ids, path, dan folder +@msg paths entry {i} needs path and ids (object ids no other entry has) entri paths {i} memerlukan path dan ids (id objek yang tidak dimiliki entri lain) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder menerima path: tautan hilang lainnya dicari di folder file tersebut Show Missing Tampilkan yang hilang Sort by Status Urutkan berdasarkan status Clear Overrides Hapus penimpaan @@ -5712,6 +5721,17 @@ axis Y Axis Sumbu Y The plug-in is computing… Plug-in sedang menghitung… Inspect Plug-in Periksa plug-in Cancel Plug-in Run Batalkan jalannya plug-in +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instal “{name}”, yang meminta izin berikut? + ID: {id} ID: {id} + Version: {version} Versi: {version} + File SHA-256: {hash} SHA-256 file: {hash} + Read the objects it is given Membaca objek yang diberikan kepadanya + Change, add and delete objects in the document Mengubah, menambah, dan menghapus objek dalam dokumen + Keep data of its own with the document Menyimpan datanya sendiri bersama dokumen + Change how the objects its effect is applied to are drawn Mengubah cara objek yang diberi efeknya digambar + Write the document as a file in its own format Menulis dokumen sebagai file dalam formatnya sendiri + Show its progress, see Cancel and write log lines Menampilkan kemajuannya, mengenali pembatalan, dan menulis baris log # Actions panel: action sets (#3712). New Set… Set baru… New Set Set baru diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 62e34497e..b19e01f96 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2741,6 +2741,14 @@ digits Malayalam Malayalam Relink Ricollega Relink Linked Files to the Document Ricollega i file collegati al documento Relink to Folder… Ricollega a cartella… + Each Link Separately Ogni collegamento separatamente + Selected Instances Istanze selezionate + All Instances Tutte le istanze + Missing Links from This Folder Collegamenti mancanti da questa cartella +@msg scope {v} is not each|selected|all|missingInFolder scope {v} non è 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 e paths vanno insieme: paths elenca voci con ids e path, al posto di ids, path e folder +@msg paths entry {i} needs path and ids (object ids no other entry has) la voce {i} di paths richiede path e ids (ID di oggetti che nessun'altra voce ha) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder accetta path: nella cartella di quel file si cercano gli altri collegamenti mancanti Relink… Ricollega… Find Linked Files Trova file collegati Find Linked Files in Folder… Trova file collegati in una cartella… @@ -2859,6 +2867,7 @@ digits Malayalam Malayalam Rotate Tool Strumento Ruota Rotate View Tool Strumento Ruota vista Trackpad Gesture to Rotate View Ruota vista con un gesto sul trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Tieni premuta la scorciatoia di uno strumento per usarlo temporaneamente Rotate… Ruota… Rotation Rotazione Roughen… Irruvidisci… @@ -5429,6 +5438,33 @@ axis Y Axis Asse Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `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}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): installalo con `allowHeavy: true` quando l’utente acconsente +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installare “{name}”, che chiede queste autorizzazioni? + ID: {id} ID: {id} + Version: {version} Versione: {version} + File SHA-256: {hash} SHA-256 del file: {hash} + Read the objects it is given Leggere gli oggetti che riceve + Change, add and delete objects in the document Modificare, aggiungere ed eliminare oggetti nel documento + Keep data of its own with the document Conservare dati propri con il documento + Change how the objects its effect is applied to are drawn Cambiare il modo in cui vengono disegnati gli oggetti a cui è applicato il suo effetto + Write the document as a file in its own format Scrivere il documento come file in un formato proprio + Show its progress, see Cancel and write log lines Mostrare il proprio avanzamento, rilevare l’annullamento e scrivere righe di registro +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `progress` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} il modulo importa `env.{name}` con un tipo diverso da quello dell’host: quello dell’host è {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` il modulo importa `env.{name}`, che un plug-in può usare solo al livello API dei plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e con l’autorizzazione `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 il modulo importa `{from}.{name}`, che l’host non offre: un plug-in può importare solo `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (livello API dei plug-in 2), e nessun WASI, file o rete +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` è {_1}; questo host supporta i livelli API dei plug-in da 1 a {API_LEVEL} +@msg `grant` must be a list of permission names `grant` deve essere un elenco di nomi di autorizzazioni +@msg `grant` names an unknown permission `{shown}` `grant` indica un’autorizzazione sconosciuta `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un plug-in di tipo `{_1}` richiede l’autorizzazione `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` al massimo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorizzazione `{shown}` non si applica a un plug-in di tipo `{_1}` +@msg permission `{shown}` is listed twice l’autorizzazione `{shown}` è elencata due volte +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede +@msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” non è stata concessa `{p}`, che serve al suo risultato: non è stato modificato nulla +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed il modulo non ha lo SHA-256 indicato da `hash`: non è stato installato nulla # Actions panel: action sets (#3712). New Set… Nuovo set… New Set Nuovo set diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c39d9a307..77d50e0a8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2730,6 +2730,14 @@ mark-style Japanese 日本式 Release to Layers (Sequence) レイヤーに分配 (シーケンス) Relink 再リンク 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、folder の代わりに、ids と path を持つエントリを並べます +@msg paths entry {i} needs path and ids (object ids no other entry has) paths のエントリ {i} には path と ids (ほかのエントリにないオブジェクト ID) が必要です +@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… フォルダー内のリンクファイルを検索… @@ -2816,6 +2824,7 @@ mark-style Japanese 日本式 Rotate Tool 回転ツール Rotate View Tool 回転ビューツール Trackpad Gesture to Rotate View トラックパッドのジェスチャーでビューを回転 + Hold a Tool Shortcut to Use the Tool Temporarily ツールのショートカットを押している間だけツールを使用 Rotation 回転 Round Cap 丸型線端 Round Join ラウンド結合 @@ -5494,6 +5503,33 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません @msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`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}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。ユーザーが同意したら `allowHeavy: true` を指定してインストールしてください +# 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 進行状況の表示、キャンセルの検知、ログ行の書き込み +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `progress` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください +@msg the module imports `env.{name}` with another type than the host's: it is {signature} モジュールが `env.{name}` をホストと異なる型でインポートしています。ホストの型は {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` モジュールが `env.{name}` をインポートしていますが、プラグインがこれを使用できるのはプラグイン API レベル {PROGRESS_API} (`api` {PROGRESS_API}) で権限 `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 モジュールがホストの提供しない `{from}.{name}` をインポートしています。プラグインがインポートできるのは `env.vc_progress`、`env.vc_cancelled`、`env.vc_log` (プラグイン API レベル 2) だけで、WASI、ファイル、ネットワークは使用できません +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` は {_1} です。このホストが対応するプラグイン API レベルは 1 から {API_LEVEL} です +@msg `grant` must be a list of permission names `grant` は権限名のリストにしてください +@msg `grant` names an unknown permission `{shown}` `grant` に不明な権限 `{shown}` が含まれています +@msg a plug-in of kind `{_1}` needs the permission `{_2}` 種類 `{_1}` のプラグインには権限 `{_2}` が必要です +@msg at most {MAX_PERMISSIONS} `permissions` `permissions` は最大 {MAX_PERMISSIONS} 個です +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 権限 `{shown}` は種類 `{_1}` のプラグインには適用されません +@msg permission `{shown}` is listed twice 権限 `{shown}` が 2 回指定されています +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません +@msg unknown permission `{shown}` 不明な権限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」には出力に必要な `{p}` が付与されていません。何も変更されませんでした +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした # Actions panel: action sets (#3712). New Set… 新規セット… New Set 新規セット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 49b8f680b..c710ee3c5 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1806,6 +1806,14 @@ digits Malayalam მალაიალამური Location მდებარეობა No images გამოსახულებები არ არის 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) paths-ის ჩანაწერს {i} სჭირდება path და ids (ობიექტის ID-ები, რომლებიც სხვა ჩანაწერს არ აქვს) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder იღებს path-ს: სხვა დაკარგული ბმულები ამ ფაილის საქაღალდეში იძებნება Show Missing დაკარგულების ჩვენება Sort by Status სტატუსის მიხედვით სორტირება Clear Overrides ცვლილებების გასუფთავება @@ -5523,6 +5531,7 @@ imageTrace Outline კონტური The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. შენახვის პანელში სახელი იყო „{typed}“; VectorCraft-მა ფაილის ტიპის გაფართოება დაამატა. ფაილის ჩანაცვლება მის შიგთავსს გადააწერს. Thread Text ტექსტის დაკავშირება Trackpad Gesture to Rotate View ხედის შემობრუნება ტრეკპადის ჟესტით + Hold a Tool Shortcut to Use the Tool Temporarily ხელსაწყოს დროებით გამოყენება მისი მალსახმობის დაჭერით Typing აკრეფა Uniformly Scaled თანაბრად მასშტაბირებული Unlock Variables ცვლადების განბლოკვა @@ -5736,6 +5745,33 @@ axis Y Axis Y ღერძი @msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy` @msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `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}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `allowHeavy: true` პარამეტრით +# 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 პროგრესის ჩვენება, გაუქმების შემჩნევა და ჟურნალის სტრიქონების ჩაწერა +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `progress` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} მოდულს შემოაქვს `env.{name}` ჰოსტისგან განსხვავებული ტიპით: ჰოსტის ტიპია {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` მოდულს შემოაქვს `env.{name}`, რომლის გამოყენებაც პლაგინს მხოლოდ პლაგინის API-ის დონეზე {PROGRESS_API} (`api` {PROGRESS_API}) და ნებართვით `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 მოდულს შემოაქვს `{from}.{name}`, რომელსაც ჰოსტი არ გვთავაზობს: პლაგინს შეუძლია შემოიტანოს მხოლოდ `env.vc_progress`, `env.vc_cancelled` და `env.vc_log` (პლაგინის API-ის დონე 2) და არა WASI, ფაილები ან ქსელი +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` არის {_1}; ეს ჰოსტი პლაგინის API-ის დონეებს 1-დან {API_LEVEL}-მდე უჭერს მხარს +@msg `grant` must be a list of permission names `grant` ნებართვების სახელების სია უნდა იყოს +@msg `grant` names an unknown permission `{shown}` `grant` უცნობ ნებართვას ასახელებს: `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` სახის პლაგინს სჭირდება ნებართვა `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` მაქსიმუმ {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` ნებართვა `{shown}` არ ეხება `{_1}` სახის პლაგინს +@msg permission `{shown}` is listed twice ნებართვა `{shown}` ორჯერაა ჩამოთვლილი +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს +@msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“-ს არ მიეცა `{p}`, რომელიც მის შედეგს სჭირდება: არაფერი შეცვლილა +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა # Actions panel: action sets (#3712). New Set… ახალი ნაკრები… New Set ახალი ნაკრები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 42fbefc5f..c0545a3da 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2719,6 +2719,14 @@ mark-style Japanese 일본식 Release to Layers (Sequence) 레이어로 풀기(순차) Relink 다시 연결 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, folder 대신 ids와 path가 있는 항목을 나열합니다 +@msg paths entry {i} needs path and ids (object ids no other entry has) paths 항목 {i}에는 path와 ids(다른 항목에 없는 개체 ID)가 필요합니다 +@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… 폴더에서 연결된 파일 찾기… @@ -2805,6 +2813,7 @@ mark-style Japanese 일본식 Rotate Tool 회전 도구 Rotate View Tool 보기 회전 도구 Trackpad Gesture to Rotate View 트랙패드 제스처로 보기 회전 + Hold a Tool Shortcut to Use the Tool Temporarily 도구 단축키를 누르고 있는 동안 도구를 임시로 사용 Rotation 회전 Round Cap 둥근 끝 Round Join 둥근 연결 @@ -5390,6 +5399,33 @@ axis Y Axis Y축 @msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다 @msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `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}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 사용자가 동의하면 `allowHeavy: true`로 설치하세요 +# 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 진행률 표시, 취소 감지 및 로그 줄 쓰기 +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오 +@msg the module imports `env.{name}` with another type than the host's: it is {signature} 모듈이 `env.{name}`을(를) 호스트와 다른 형식으로 가져옵니다. 호스트의 형식은 {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` 모듈이 `env.{name}`을(를) 가져오지만, 플러그인은 이를 플러그인 API 수준 {PROGRESS_API}(`api` {PROGRESS_API})에서 `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 모듈이 호스트가 제공하지 않는 `{from}.{name}`을(를) 가져옵니다. 플러그인은 `env.vc_progress`, `env.vc_cancelled`, `env.vc_log`(플러그인 API 수준 2)만 가져올 수 있으며 WASI, 파일, 네트워크는 사용할 수 없습니다 +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api`가 {_1}입니다. 이 호스트는 플러그인 API 수준 1부터 {API_LEVEL}까지 지원합니다 +@msg `grant` must be a list of permission names `grant`는 권한 이름 목록이어야 합니다 +@msg `grant` names an unknown permission `{shown}` `grant`에 알 수 없는 권한 `{shown}`이(가) 있습니다 +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 종류의 플러그인에는 `{_2}` 권한이 필요합니다 +@msg at most {MAX_PERMISSIONS} `permissions` `permissions`는 최대 {MAX_PERMISSIONS}개입니다 +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` 권한은 `{_1}` 종류의 플러그인에 적용되지 않습니다 +@msg permission `{shown}` is listed twice `{shown}` 권한이 두 번 나열되어 있습니다 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다 +@msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”에 출력에 필요한 `{p}` 권한이 부여되지 않았습니다. 아무것도 변경되지 않았습니다 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 # Actions panel: action sets (#3712). New Set… 새 세트… New Set 새 세트 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ebcf89356..72d4ea21a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1246,6 +1246,7 @@ lighting Light Licht Rotate Tool Draaien-gereedschap Rotate View Tool Weergave draaien-gereedschap Trackpad Gesture to Rotate View Weergave draaien met trackpadgebaar + Hold a Tool Shortcut to Use the Tool Temporarily Sneltoets van een gereedschap ingedrukt houden om het tijdelijk te gebruiken Rotate View Weergave draaien Rounded Rectangle Tool Afgeronde rechthoek-gereedschap Scale Tool Schalen-gereedschap @@ -1912,6 +1913,14 @@ digits Malayalam Malayalam Location Locatie No images Geen afbeeldingen Relink to Folder… Opnieuw koppelen aan map… + Each Link Separately Elke koppeling afzonderlijk + Selected Instances Geselecteerde instanties + All Instances Alle instanties + Missing Links from This Folder Ontbrekende koppelingen uit deze map +@msg scope {v} is not each|selected|all|missingInFolder scope {v} is niet 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 en paths horen bij elkaar: paths bevat items met ids en path, in plaats van ids, path en folder +@msg paths entry {i} needs path and ids (object ids no other entry has) item {i} van paths heeft path en ids nodig (object-id's die geen ander item heeft) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder neemt path: in de map van dat bestand wordt naar de andere ontbrekende koppelingen gezocht Show Missing Ontbrekende weergeven Sort by Status Sorteren op status Clear Overrides Onderdrukkingen wissen @@ -5758,6 +5767,17 @@ axis Y Axis Y-as The plug-in is computing… De plug-in is aan het rekenen… Inspect Plug-in Plug-in inspecteren Cancel Plug-in Run Plug-in-run annuleren +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? „{name}” installeren, dat om deze machtigingen vraagt? + ID: {id} ID: {id} + Version: {version} Versie: {version} + File SHA-256: {hash} SHA-256 van het bestand: {hash} + Read the objects it is given De objecten lezen die het krijgt + Change, add and delete objects in the document Objecten in het document wijzigen, toevoegen en verwijderen + Keep data of its own with the document Eigen gegevens bij het document bewaren + Change how the objects its effect is applied to are drawn Wijzigen hoe de objecten waarop het effect is toegepast worden getekend + Write the document as a file in its own format Het document als bestand in een eigen indeling schrijven + Show its progress, see Cancel and write log lines De voortgang tonen, een annulering zien en logregels schrijven # Actions panel: action sets (#3712). New Set… Nieuwe set… New Set Nieuwe set diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 512690d39..ff2b9bd6b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1241,6 +1241,7 @@ lighting Light Światło Rotate Tool Narzędzie Obrót Rotate View Tool Narzędzie Obrót widoku Trackpad Gesture to Rotate View Obracaj widok gestem na gładziku + Hold a Tool Shortcut to Use the Tool Temporarily Używaj narzędzia tymczasowo, przytrzymując jego skrót Rounded Rectangle Tool Narzędzie Zaokrąglony prostokąt Scale Tool Narzędzie Skalowanie Scallop Tool Narzędzie Ząbkowanie @@ -2012,6 +2013,14 @@ digits Malayalam Malajalam Location Położenie No images Brak obrazów Relink to Folder… Połącz ponownie z folderem… + Each Link Separately Każde łącze osobno + Selected Instances Zaznaczone wystąpienia + All Instances Wszystkie wystąpienia + Missing Links from This Folder Brakujące łącza z tego folderu +@msg scope {v} is not each|selected|all|missingInFolder scope {v} nie jest jednym z 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 występują razem: paths wymienia wpisy z ids i path zamiast ids, path i folder +@msg paths entry {i} needs path and ids (object ids no other entry has) wpis {i} w paths wymaga path i ids (identyfikatorów obiektów, których nie ma żaden inny wpis) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder przyjmuje path: w folderze tego pliku szukane są pozostałe brakujące łącza Show Missing Pokaż brakujące Sort by Status Sortuj według stanu Clear Overrides Wyczyść zastąpienia @@ -5666,6 +5675,33 @@ axis Y Axis Oś Y @msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy` @msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `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}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): zainstaluj wtyczkę z `allowHeavy: true`, gdy użytkownik się zgodzi +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Zainstalować „{name}”, wtyczkę, która prosi o te uprawnienia? + ID: {id} ID: {id} + Version: {version} Wersja: {version} + File SHA-256: {hash} SHA-256 pliku: {hash} + Read the objects it is given Odczytywać obiekty, które otrzymuje + Change, add and delete objects in the document Zmieniać, dodawać i usuwać obiekty w dokumencie + Keep data of its own with the document Przechowywać własne dane w dokumencie + Change how the objects its effect is applied to are drawn Zmieniać sposób rysowania obiektów, do których zastosowano jej efekt + Write the document as a file in its own format Zapisywać dokument jako plik we własnym formacie + Show its progress, see Cancel and write log lines Pokazywać swój postęp, wykrywać anulowanie i zapisywać wiersze dziennika +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `progress` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} moduł importuje `env.{name}` z innym typem niż typ hosta: typ hosta to {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` moduł importuje `env.{name}`, którego wtyczka może używać tylko na poziomie API wtyczek {PROGRESS_API} (`api` {PROGRESS_API}) i z uprawnieniem `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 moduł importuje `{from}.{name}`, którego host nie udostępnia: wtyczka może importować tylko `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (poziom API wtyczek 2), bez WASI, plików i sieci +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ma wartość {_1}; ten host obsługuje poziomy API wtyczek od 1 do {API_LEVEL} +@msg `grant` must be a list of permission names `grant` musi być listą nazw uprawnień +@msg `grant` names an unknown permission `{shown}` `grant` wymienia nieznane uprawnienie `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` wtyczka rodzaju `{_1}` potrzebuje uprawnienia `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` najwyżej {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` uprawnienie `{shown}` nie dotyczy wtyczki rodzaju `{_1}` +@msg permission `{shown}` is listed twice uprawnienie `{shown}` występuje dwa razy +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje +@msg unknown permission `{shown}` nieznane uprawnienie `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nie otrzymała `{p}`, którego potrzebuje jej wynik: nic nie zmieniono +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduł nie ma skrótu SHA-256 podanego w `hash`: nic nie zainstalowano # Actions panel: action sets (#3712). New Set… Nowy zestaw… New Set Nowy zestaw diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f17fb4464..3340597d8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2259,6 +2259,14 @@ digits Malayalam Malaiala Release with Perspective Liberar com Perspectiva Relink Revinculcar Relink to Folder… Revinculcar Pasta… + Each Link Separately Cada Vínculo Separadamente + Selected Instances Instâncias Selecionadas + All Instances Todas as Instâncias + Missing Links from This Folder Vínculos Ausentes Desta Pasta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} não é 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 e paths andam juntos: paths lista entradas com ids e path, no lugar de ids, path e folder +@msg paths entry {i} needs path and ids (object ids no other entry has) a entrada {i} de paths precisa de path e ids (ids de objeto que nenhuma outra entrada tenha) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder recebe path: os outros vínculos ausentes são procurados na pasta desse arquivo Relink… Revinculcar… Reload Plug-ins Recarregar Plug-ins Remove Remover @@ -2360,6 +2368,7 @@ digits Malayalam Malaiala Rotate Tool Ferramenta Girar Rotate View Tool Ferramenta Girar Visualização Trackpad Gesture to Rotate View Girar Visualização com Gesto do Trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Manter o Atalho de uma Ferramenta Pressionado para Usá-la Temporariamente Rotate… Girar… Rotation Rotação Roughen… Enrugar… @@ -5728,6 +5737,33 @@ axis Y Axis Eixo Y @msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `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}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): instale-o com `allowHeavy: true` depois que o usuário concordar +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instalar “{name}”, que pede estas permissões? + ID: {id} ID: {id} + Version: {version} Versão: {version} + File SHA-256: {hash} SHA-256 do arquivo: {hash} + Read the objects it is given Ler os objetos que recebe + Change, add and delete objects in the document Alterar, adicionar e excluir objetos no documento + Keep data of its own with the document Guardar dados próprios com o documento + Change how the objects its effect is applied to are drawn Alterar como são desenhados os objetos aos quais seu efeito é aplicado + Write the document as a file in its own format Gravar o documento como um arquivo em formato próprio + Show its progress, see Cancel and write log lines Mostrar seu progresso, detectar o cancelamento e gravar linhas de log +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `progress` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} o módulo importa `env.{name}` com um tipo diferente do tipo do host: o do host é {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` o módulo importa `env.{name}`, que um plug-in só pode usar no nível de API de plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e com a permissão `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 o módulo importa `{from}.{name}`, que o host não oferece: um plug-in só pode importar `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (nível de API de plug-in 2), e nenhum WASI, arquivo ou rede +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` é {_1}; este host aceita os níveis de API de plug-in de 1 a {API_LEVEL} +@msg `grant` must be a list of permission names `grant` deve ser uma lista de nomes de permissões +@msg `grant` names an unknown permission `{shown}` `grant` cita uma permissão desconhecida `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` um plug-in do tipo `{_1}` precisa da permissão `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` no máximo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a permissão `{shown}` não se aplica a um plug-in do tipo `{_1}` +@msg permission `{shown}` is listed twice a permissão `{shown}` aparece duas vezes +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede +@msg unknown permission `{shown}` permissão desconhecida `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” não recebeu `{p}`, de que sua saída precisa: nada foi alterado +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed o módulo não tem o SHA-256 indicado em `hash`: nada foi instalado # Actions panel: action sets (#3712). New Set… Novo Conjunto… New Set Novo Conjunto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0f200c341..3732558f7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2736,6 +2736,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… Найти связанные файлы в папке… @@ -2854,6 +2862,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… Огрубление… @@ -5424,6 +5433,33 @@ axis Y Axis Ось Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `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}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): установите его с `allowHeavy: true`, когда пользователь согласится +# 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 Показывать свой ход выполнения, замечать отмену и записывать строки журнала +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `progress` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль импортирует `env.{name}` с другим типом, чем у хоста: у хоста тип {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` модуль импортирует `env.{name}`, который модуль может использовать только на уровне API модулей {PROGRESS_API} (`api` {PROGRESS_API}) и с разрешением `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 модуль импортирует `{from}.{name}`, который хост не предоставляет: модуль может импортировать только `env.vc_progress`, `env.vc_cancelled` и `env.vc_log` (уровень API модулей 2), без WASI, файлов и сети +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` равно {_1}; этот хост поддерживает уровни API модулей от 1 до {API_LEVEL} +@msg `grant` must be a list of permission names `grant` должен быть списком имён разрешений +@msg `grant` names an unknown permission `{shown}` `grant` называет неизвестное разрешение `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю вида `{_1}` нужно разрешение `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` не более {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` разрешение `{shown}` не относится к модулю вида `{_1}` +@msg permission `{shown}` is listed twice разрешение `{shown}` указано дважды +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт +@msg unknown permission `{shown}` неизвестное разрешение `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не получил разрешение `{p}`, нужное для его результата: ничего не изменено +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено # Actions panel: action sets (#3712). New Set… Новый набор… New Set Новый набор diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5ed1cf9ca..58ceab701 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2644,6 +2644,14 @@ digits Malayalam Malayalam Relink Länka om Relink Linked Files to the Document Länka om länkade filer till dokumentet Relink to Folder… Länka om till mapp… + Each Link Separately Varje länk separat + Selected Instances Markerade instanser + All Instances Alla instanser + Missing Links from This Folder Saknade länkar från den här mappen +@msg scope {v} is not each|selected|all|missingInFolder scope {v} är inte 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 och paths hör ihop: paths listar poster med ids och path i stället för ids, path och folder +@msg paths entry {i} needs path and ids (object ids no other entry has) post {i} i paths behöver path och ids (objekt-id:n som ingen annan post har) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder tar path: de andra saknade länkarna söks i den filens mapp Relink… Länka om… Find Linked Files Sök länkade filer Find Linked Files in Folder… Sök länkade filer i mapp… @@ -2761,6 +2769,7 @@ digits Malayalam Malayalam Rotate Tool Rotationsverktyg Rotate View Tool Verktyget Rotera vy Trackpad Gesture to Rotate View Rotera vyn med en gest på styrplattan + Hold a Tool Shortcut to Use the Tool Temporarily Håll ned ett verktygs kortkommando för att använda verktyget tillfälligt Rotate… Rotera… Rotation Rotation Roughen… Ojämn… @@ -5836,6 +5845,33 @@ axis Y Axis Y-axel @msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy` @msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `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}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): installera det med `allowHeavy: true` när användaren har godkänt +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installera ”{name}”, som begär dessa behörigheter? + ID: {id} ID: {id} + Version: {version} Version: {version} + File SHA-256: {hash} Filens SHA-256: {hash} + Read the objects it is given Läsa objekten den får + Change, add and delete objects in the document Ändra, lägga till och ta bort objekt i dokumentet + Keep data of its own with the document Spara egna data med dokumentet + Change how the objects its effect is applied to are drawn Ändra hur objekten som dess effekt används på ritas + Write the document as a file in its own format Skriva dokumentet som en fil i sitt eget format + Show its progress, see Cancel and write log lines Visa sitt förlopp, se när det avbryts och skriva loggrader +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `progress` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} modulen importerar `env.{name}` med en annan typ än värdens: värdens typ är {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` modulen importerar `env.{name}`, som ett plugin bara får använda på plugin-API-nivå {PROGRESS_API} (`api` {PROGRESS_API}) och med behörigheten `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 modulen importerar `{from}.{name}`, som värden inte erbjuder: ett plugin får bara importera `env.vc_progress`, `env.vc_cancelled` och `env.vc_log` (plugin-API-nivå 2), och ingen WASI, inga filer eller nätverk +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` är {_1}; den här värden stöder plugin-API-nivå 1 till {API_LEVEL} +@msg `grant` must be a list of permission names `grant` måste vara en lista med behörighetsnamn +@msg `grant` names an unknown permission `{shown}` `grant` anger en okänd behörighet `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` ett plugin av typen `{_1}` behöver behörigheten `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` högst {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` behörigheten `{shown}` gäller inte ett plugin av typen `{_1}` +@msg permission `{shown}` is listed twice behörigheten `{shown}` står två gånger +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte +@msg unknown permission `{shown}` okänd behörighet `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulen har inte den SHA-256 som `hash` anger: ingenting installerades # Actions panel: action sets (#3712). New Set… Ny uppsättning… New Set Ny uppsättning @@ -7737,7 +7773,6 @@ digits Hindi Hindi @msg the library has no item `{other}` biblioteket har inget objekt `{other}` @msg the library holds {MAX_GROUPS} groups already biblioteket innehåller redan {MAX_GROUPS} grupper @msg the library holds {MAX_ITEMS} items of this kind already biblioteket innehåller redan {MAX_ITEMS} objekt av den här typen -@msg the module imports `{_1}.{_2}`, but plug-ins get no host imports (no WASI, files or network) modulen importerar `{_1}.{_2}`, men plugins får inga värdimporter (ingen WASI, inga filer eller nätverk) @msg the module must export its linear memory as `memory` modulen måste exportera sitt linjära minne som `memory` @msg the name is empty namnet är tomt @msg the object has no fill, stroke or effect objektet har ingen fyllning, kontur eller effekt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c4ec25012..9385b30cc 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2514,6 +2514,14 @@ digits Malayalam Malayalam Relink Yeniden bağla Relink Linked Files to the Document Bağlantılı Dosyaları Belgeye Yeniden Bağlama Relink to Folder… Klasöre Yeniden Bağlan… + Each Link Separately Her Bağlantıyı Ayrı Ayrı + Selected Instances Seçili Örnekler + All Instances Tüm Örnekler + Missing Links from This Folder Bu Klasördeki Eksik Bağlantılar +@msg scope {v} is not each|selected|all|missingInFolder scope {v}, each|selected|all|missingInFolder değerlerinden biri değil +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each ve paths birlikte kullanılır: paths, ids, path ve folder yerine ids ve path içeren girdileri listeler +@msg paths entry {i} needs path and ids (object ids no other entry has) paths girdisi {i} için path ve ids gerekir (başka hiçbir girdide olmayan nesne kimlikleri) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder path alır: diğer eksik bağlantılar o dosyanın klasöründe aranır Relink… Yeniden bağla… Find Linked Files Bağlantılı Dosyaları Bul Find Linked Files in Folder… Klasördeki Bağlantılı Dosyaları Bul… @@ -5440,6 +5448,7 @@ theme Appearance Görünüm The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Kayıt panelindeki ad “{typed}” idi; VectorCraft dosya türünün uzantısını ekledi. Dosyayı değiştirmek içeriğinin üzerine yazar. Thread Text Metni Bağla Trackpad Gesture to Rotate View Görünümü Döndürmek İçin İzleme Dörtgeni Hareketi + Hold a Tool Shortcut to Use the Tool Temporarily Aracı Geçici Olarak Kullanmak İçin Kısayolunu Basılı Tutma Typing Yazma Uniformly Scaled Tekdüze Ölçeklenmiş Unlock Variables Değişkenlerin Kilidini Aç @@ -5770,6 +5779,33 @@ axis Y Axis Y ekseni @msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz @msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın @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}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): kullanıcı onayladıktan sonra `allowHeavy: true` ile yükleyin +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Bu izinleri isteyen “{name}” yüklensin mi? + ID: {id} Kimlik: {id} + Version: {version} Sürüm: {version} + File SHA-256: {hash} Dosya SHA-256: {hash} + Read the objects it is given Kendisine verilen nesneleri okuma + Change, add and delete objects in the document Belgedeki nesneleri değiştirme, ekleme ve silme + Keep data of its own with the document Kendi verilerini belgeyle birlikte saklama + Change how the objects its effect is applied to are drawn Efektinin uygulandığı nesnelerin nasıl çizildiğini değiştirme + Write the document as a file in its own format Belgeyi kendi biçiminde bir dosya olarak yazma + Show its progress, see Cancel and write log lines İlerlemesini gösterme, iptali algılama ve günlük satırları yazma +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin +@msg the module imports `env.{name}` with another type than the host's: it is {signature} modül `env.{name}` öğesini ana bilgisayarınkinden farklı bir türle içe aktarıyor: ana bilgisayarın türü {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` modül `env.{name}` öğesini içe aktarıyor; bir eklenti bunu yalnızca eklenti API düzeyi {PROGRESS_API} (`api` {PROGRESS_API}) ile ve `progress` izniyle kullanabilir +@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 modül, ana bilgisayarın sunmadığı `{from}.{name}` öğesini içe aktarıyor: bir eklenti yalnızca `env.vc_progress`, `env.vc_cancelled` ve `env.vc_log` öğelerini (eklenti API düzeyi 2) içe aktarabilir; WASI, dosya veya ağ kullanamaz +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` değeri {_1}; bu ana bilgisayar 1 ile {API_LEVEL} arasındaki eklenti API düzeylerini destekler +@msg `grant` must be a list of permission names `grant` izin adlarından oluşan bir liste olmalıdır +@msg `grant` names an unknown permission `{shown}` `grant` bilinmeyen bir izin adlandırıyor: `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` türündeki bir eklentinin `{_2}` iznine ihtiyacı vardır +@msg at most {MAX_PERMISSIONS} `permissions` en fazla {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` izni `{_1}` türündeki bir eklentiye uygulanmaz +@msg permission `{shown}` is listed twice `{shown}` izni iki kez listelenmiş +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez +@msg unknown permission `{shown}` bilinmeyen izin `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” öğesine çıktısının ihtiyaç duyduğu `{p}` izni verilmedi: hiçbir şey değiştirilmedi +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modül `hash` içinde verilen SHA-256 değerine sahip değil: hiçbir şey yüklenmedi # Actions panel: action sets (#3712). New Set… Yeni Set… New Set Yeni Set diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ae131c2e5..1b96c235e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2733,6 +2733,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… Знайти пов’язані файли в папці… @@ -2851,6 +2859,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… Надати нерівності… @@ -5409,6 +5418,33 @@ axis Y Axis Вісь Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `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}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): встановіть його з `allowHeavy: true`, коли користувач погодиться +# 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 Показувати свій поступ, помічати скасування і записувати рядки журналу +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `progress` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль імпортує `env.{name}` з іншим типом, ніж у хоста: у хоста тип {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` модуль імпортує `env.{name}`, який модуль може використовувати лише на рівні API модулів {PROGRESS_API} (`api` {PROGRESS_API}) і з дозволом `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 модуль імпортує `{from}.{name}`, який хост не надає: модуль може імпортувати лише `env.vc_progress`, `env.vc_cancelled` і `env.vc_log` (рівень API модулів 2), без WASI, файлів і мережі +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` дорівнює {_1}; цей хост підтримує рівні API модулів від 1 до {API_LEVEL} +@msg `grant` must be a list of permission names `grant` має бути списком назв дозволів +@msg `grant` names an unknown permission `{shown}` `grant` називає невідомий дозвіл `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю виду `{_1}` потрібен дозвіл `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` щонайбільше {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` дозвіл `{shown}` не стосується модуля виду `{_1}` +@msg permission `{shown}` is listed twice дозвіл `{shown}` указано двічі +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає +@msg unknown permission `{shown}` невідомий дозвіл `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не отримав дозволу `{p}`, потрібного для його результату: нічого не змінено +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено # Actions panel: action sets (#3712). New Set… Новий набір… New Set Новий набір diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f36b874d1..ce98bc978 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3007,6 +3007,14 @@ mark-style Japanese Kiểu Nhật Relink Liên kết lại Relink Linked Files to the Document Liên kết lại các tệp liên kết với tài liệu Relink to Folder… Liên kết lại vào thư mục… + Each Link Separately Từng liên kết riêng + Selected Instances Các phiên bản đã chọn + All Instances Tất cả phiên bản + Missing Links from This Folder Liên kết thiếu từ thư mục này +@msg scope {v} is not each|selected|all|missingInFolder scope {v} không phải 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 và paths đi cùng nhau: paths liệt kê các mục có ids và path, thay cho ids, path và folder +@msg paths entry {i} needs path and ids (object ids no other entry has) mục {i} của paths cần path và ids (ID đối tượng mà không mục nào khác có) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder nhận path: các liên kết thiếu khác được tìm trong thư mục của tệp đó Relink… Liên kết lại… Reload Plug-ins Tải lại plug-in Remove Gỡ @@ -3147,6 +3155,7 @@ mark-style Japanese Kiểu Nhật Rotate Tool Công cụ Xoay Rotate View Tool Công cụ Xoay chế độ xem Trackpad Gesture to Rotate View Xoay chế độ xem bằng cử chỉ trên bàn di chuột + Hold a Tool Shortcut to Use the Tool Temporarily Giữ phím tắt của công cụ để tạm thời dùng công cụ đó Rotate… Xoay… Rotation Góc xoay Rotation relative to: Xoay tương đối với: @@ -5410,6 +5419,33 @@ axis Y Axis Trục Y @msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy` @msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `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}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): hãy cài đặt với `allowHeavy: true` khi người dùng đồng ý +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Cài đặt “{name}”, plug-in yêu cầu các quyền này? + ID: {id} ID: {id} + Version: {version} Phiên bản: {version} + File SHA-256: {hash} SHA-256 của tệp: {hash} + Read the objects it is given Đọc các đối tượng được trao cho nó + Change, add and delete objects in the document Thay đổi, thêm và xóa đối tượng trong tài liệu + Keep data of its own with the document Lưu dữ liệu riêng cùng tài liệu + Change how the objects its effect is applied to are drawn Thay đổi cách vẽ các đối tượng được áp dụng hiệu ứng của nó + Write the document as a file in its own format Ghi tài liệu thành tệp theo định dạng riêng + Show its progress, see Cancel and write log lines Hiển thị tiến độ, nhận biết yêu cầu hủy và ghi các dòng nhật ký +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `progress` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} mô-đun nhập `env.{name}` với kiểu khác với kiểu của máy chủ: kiểu của máy chủ là {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` mô-đun nhập `env.{name}`, thứ mà plug-in chỉ được dùng ở API plug-in cấp {PROGRESS_API} (`api` {PROGRESS_API}) và với quyền `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 mô-đun nhập `{from}.{name}`, thứ mà máy chủ không cung cấp: plug-in chỉ được nhập `env.vc_progress`, `env.vc_cancelled` và `env.vc_log` (API plug-in cấp 2), không có WASI, tệp hay mạng +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` là {_1}; máy chủ này hỗ trợ các mức API plug-in từ 1 đến {API_LEVEL} +@msg `grant` must be a list of permission names `grant` phải là danh sách tên quyền +@msg `grant` names an unknown permission `{shown}` `grant` nêu một quyền không xác định `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` plug-in loại `{_1}` cần quyền `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` tối đa {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` quyền `{shown}` không áp dụng cho plug-in loại `{_1}` +@msg permission `{shown}` is listed twice quyền `{shown}` được liệt kê hai lần +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó +@msg unknown permission `{shown}` quyền không xác định `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” chưa được cấp `{p}`, quyền mà đầu ra của nó cần: không có gì thay đổi +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed mô-đun không có SHA-256 mà `hash` cho biết: không có gì được cài đặt # Actions panel: action sets (#3712). New Set… Bộ mới… New Set Bộ mới diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index dafd7dd3c..a2cf40630 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2612,6 +2612,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) paths 条目 {i} 需要 path 和 ids(其他条目没有的对象 ID) +@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… 在文件夹中查找链接文件… @@ -2728,6 +2736,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… 粗糙效果… @@ -5651,6 +5660,33 @@ axis Y Axis Y 轴 @msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `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}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):请在用户同意后使用 `allowHeavy: true` 安装 +# 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 显示进度、检测取消请求并写入日志行 +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `progress` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} 模块以与宿主不同的类型导入 `env.{name}`:宿主的类型为 {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` 模块导入了 `env.{name}`,增效工具只有在增效工具 API 级别 {PROGRESS_API}(`api` {PROGRESS_API})且具有权限 `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 模块导入了宿主不提供的 `{from}.{name}`:增效工具只能导入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效工具 API 级别 2),不能使用 WASI、文件或网络 +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 为 {_1};此主机支持的增效工具 API 级别为 1 到 {API_LEVEL} +@msg `grant` must be a list of permission names `grant` 必须是权限名称的列表 +@msg `grant` names an unknown permission `{shown}` `grant` 包含未知权限 `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 类型的增效工具需要权限 `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 个 `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 权限 `{shown}` 不适用于 `{_1}` 类型的增效工具 +@msg permission `{shown}` is listed twice 权限 `{shown}` 列出了两次 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限 +@msg unknown permission `{shown}` 未知权限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”未获授予其输出所需的 `{p}`:未作任何更改 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 # Actions panel: action sets (#3712). New Set… 新建组… New Set 新建组 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 10f0df2b9..9440e225f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3606,6 +3606,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) paths 項目 {i} 需要 path 與 ids(其他項目沒有的物件 ID) +@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… 在資料夾中尋找連結檔案… @@ -3724,6 +3732,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… 粗糙效果… @@ -5447,6 +5456,33 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `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}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):請在使用者同意後以 `allowHeavy: true` 安裝 +# 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 顯示進度、偵測取消要求並寫入記錄行 +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `progress` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} 模組以與主機不同的類型匯入 `env.{name}`:主機的類型為 {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` 模組匯入了 `env.{name}`,增效模組只有在增效模組 API 層級 {PROGRESS_API}(`api` {PROGRESS_API})且具有權限 `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 模組匯入了主機未提供的 `{from}.{name}`:增效模組只能匯入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效模組 API 層級 2),不能使用 WASI、檔案或網路 +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 為 {_1};此主機支援的增效模組 API 層級為 1 到 {API_LEVEL} +@msg `grant` must be a list of permission names `grant` 必須是權限名稱的清單 +@msg `grant` names an unknown permission `{shown}` `grant` 包含未知權限 `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 類型的增效模組需要權限 `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 個 `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 權限 `{shown}` 不適用於 `{_1}` 類型的增效模組 +@msg permission `{shown}` is listed twice 權限 `{shown}` 列出了兩次 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限 +@msg unknown permission `{shown}` 未知權限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」未獲授予其輸出所需的 `{p}`:未做任何變更 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 # Actions panel: action sets (#3712). New Set… 新增組… New Set 新增組 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index d9d2b470a..4f507fe30 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -1,3 +1,5 @@ +use vectorcraft_plugins::permissions; + use super::save::suggested; use super::{Dialog, FilePick, Format, Value, VectorcraftApp, dialogs, fileio, json}; @@ -163,32 +165,78 @@ pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result String { + let words = match permissions::Permission::parse(name) { + Some(permissions::Permission::DocRead) => tl!("Read the objects it is given"), + Some(permissions::Permission::DocWrite) => tl!("Change, add and delete objects in the document"), + Some(permissions::Permission::State) => tl!("Keep data of its own with the document"), + Some(permissions::Permission::EffectRender) => tl!("Change how the objects its effect is applied to are drawn"), + Some(permissions::Permission::ExportWrite) => tl!("Write the document as a file in its own format"), + Some(permissions::Permission::Progress) => tl!("Show its progress, see Cancel and write log lines"), + None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD), + }; + words.to_string() +} + /// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` -/// with `name`) → `plugin.install`'s result. A filter that asks for a heavy budget the user hasn't -/// allowed yet (`allowHeavy`) asks them first, and OK installs it → `{pending}` while it asks. +/// with `name`) → `plugin.install`'s result. A plug-in that lists permissions the user hasn't +/// granted it yet (`grant`) asks them first, showing its name, id, version, file hash and what each +/// permission lets it do, and so does a filter that asks for a heavy budget the user hasn't allowed +/// yet (`allowHeavy`); OK installs it if the module still has the hash the prompt showed, Cancel +/// installs nothing → `{pending}` while it asks. Every +/// text from the manifest is shown with its hidden characters escaped and its length capped +/// ([`permissions::display_text`]). pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { - if module.get("allowHeavy").and_then(Value::as_bool) != Some(true) { + let heavy_allowed = module.get("allowHeavy").and_then(Value::as_bool) == Some(true); + let granted = module.get("grant").is_some_and(|g| !g.is_null()); + if !heavy_allowed || !granted { let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); - let info = app.run("plugin.inspect", source.clone())?; - if info["budget"] == "heavy" { - let name = info["name"].as_str().unwrap_or_default(); + // What OK installs: the same module, with what the user has agreed to so far, pinned to + // the bytes the prompt shows (`hash`). + let mut answered = + Value::Object(["path", "dataBase64", "name", "replace", "grant", "allowHeavy", "hash"].into_iter().filter_map(given).collect()); + let info = app.run("plugin.inspect", source)?; + if answered.get("hash").is_none() { + answered["hash"] = info["hash"].clone(); + } + let shown = |k: &str, max: usize| permissions::display_text(info[k].as_str().unwrap_or_default(), max); + let name = shown("name", permissions::MAX_SHOWN_NAME); + if !granted && info["needsApproval"] == true { + let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)]); + let mut lines = vec![crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))])]; + let version = shown("version", permissions::MAX_SHOWN_FIELD); + if !version.is_empty() { + lines.push(crate::i18n::fmt(tl!("Version: {version}"), &[("version", &version)])); + } + lines.push(crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))])); + lines.push(String::new()); + let asked = info["permissions"].as_array().cloned().unwrap_or_default(); + lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p)))); + let mut again = answered; + again["grant"] = Value::Array(asked); + dialogs::confirm::ask(app, &message, &lines.join("\n"), "ui.installPlugin", again); + return Ok(json!({ "pending": dialogs::confirm::KIND })); + } + if !heavy_allowed && info["budget"] == "heavy" { let (minutes, times) = vectorcraft_plugins::heavy_terms(); - let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", name)]); + let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)]); let detail = crate::i18n::fmt( tl!( "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." ), &[("minutes", &minutes.to_string()), ("times", ×.to_string())], ); - let mut allowed = source; + let mut allowed = answered; allowed["allowHeavy"] = json!(true); dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed); return Ok(json!({ "pending": dialogs::confirm::KIND })); } } let r = app.run("plugin.install", module)?; - app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); + app.status(format!("Installed plug-in {}", permissions::display_text(r["name"].as_str().unwrap_or_default(), permissions::MAX_SHOWN_NAME))); Ok(r) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 21c000c12..fb8912455 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -315,6 +315,9 @@ pub struct VectorcraftApp { /// The context, viewport and frame `shortcuts::handle` already took in this host iteration. /// Interleaving root and auxiliary viewport passes cannot make an older frame look new again. pub(crate) shortcuts_frames: Vec, + /// The single-key tool shortcuts held down, the last held last, and the tool each goes back to + /// when it is let go ([`shortcuts::HeldTool`]). + pub(crate) held_tool: Vec, /// A synthetic batch is in the host's raw input and no settled UI pass has taken it yet. synthetic_injected: bool, /// The exact events of [`Self::synthetic_injected`], to remove them if the state they belong diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 104b20459..f44359973 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -851,6 +851,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::links::open_file(app, p, false), "links.reveal" => crate::panels::links::open_file(app, p, true), "ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p), + "ui.relinkDialog" => crate::dialogs::relink::open(app, p), "ui.findLinksInFolder" => { crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned()) } diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index a1c15df20..ecaca0c6e 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -281,6 +281,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.dxfOptionsDialog" => app.session.active().is_some(), "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app), "ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app), + "ui.relinkDialog" => app.services.pick_open.is_some() && (selected_image(app, |_| true) || selected_placed(app)), "ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), "ui.batchDialog" => !app.ui.action_sets.is_empty() && app.ui.action_sets.iter().any(|s| !s.actions.is_empty()), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 7eb828313..208abcfeb 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1259,6 +1259,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions", ), + ( + "ui.relinkDialog", + "Relink…", + "", + "{ids?: [image or placed document ids]} open Relink for `ids` (default: the selected ones; dialog `relink`: ids, links ([{name, ids}], one row per linked file), scope (Replace: each|selected (default)|all|missingInFolder, see links.relink), path (picked when empty), paths (with each: the files chosen so far, one per row of links)); OK runs links.relink with them, one undo step, except with each: OK takes path, or picks a file, for the next row of links, and the last row's OK relinks them all. An error when nothing is given or selected", + ), ( "ui.findLinksInFolder", "Find Linked Files in Folder…", @@ -1445,7 +1451,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A filter that asks for a heavy budget asks the user first: OK installs it → plugin.install's result | {pending: \"confirm\"} while it asks", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget), grant?: [permission] (the permissions the user granted it), replace?: bool, hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, and so does a filter that asks for a heavy budget: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing → plugin.install's result | {pending: \"confirm\"} while it asks", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index a7fe30185..38f984876 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -2,8 +2,8 @@ //! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects //! its image, Ctrl/Cmd-click adds or removes it, and Shift-click selects the images of the rows //! from the last row clicked without Shift to it (with Ctrl/Cmd, adds them); the buttons Relink, -//! Go To, Update and Edit Original act on the selected rows; Link Info shows the details of the -//! first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: +//! Go To, Update and Edit Original act on the selected rows (Relink through its dialog, with the +//! Replace choice: `ui.relinkDialog`); Link Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: //! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Select Linked / Embedded / Missing / //! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, thumbnail size //! and Show in Folder. @@ -128,16 +128,19 @@ pub(crate) fn updated_status(n: usize) -> String { crate::i18n::msg_n(n as u64, "Updated {n} image", "Updated {n} images", &[]) } -/// Relink images `ids` to a picked file. -pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec) { - crate::picks::button(app, move |app| relink_picked(app, ids.clone())); +/// The status bar's line for `n` relinked images, naming the files `missed` that weren't found. +pub(crate) fn relinked_status_missed(n: usize, missed: &[&str]) -> String { + if missed.is_empty() { + relinked_status(n) + } else { + let (one, other) = ("Relinked {n} image; not in that folder: {names}", "Relinked {n} images; not in that folder: {names}"); + crate::i18n::msg_n(n as u64, one, other, &[("names", &missed.join(", "))]) + } } -fn relink_picked(app: &mut VectorcraftApp, ids: Vec) { - let pick = crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() }; - let Some(path) = crate::picks::open(app, &pick) else { return }; - let r = app.run("links.relink", json!({ "ids": ids, "path": path })); - report(app, r, |v| relinked_status(v["relinked"].as_array().map_or(0, Vec::len))); +/// The ids of the selected image objects and placed documents (Relink's dialog). +pub(crate) fn selected_ids(app: &VectorcraftApp) -> Vec { + selected_images(app) } /// Replace images `ids` with a picked file (Control bar, Properties): it keeps its own aspect @@ -163,13 +166,7 @@ fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec) { let r = app.run("links.relink", json!({ "ids": ids, "folder": folder })); report(app, r, |v| { let missed: Vec<&str> = v["notFound"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - let n = v["relinked"].as_array().map_or(0, Vec::len); - if missed.is_empty() { - relinked_status(n) - } else { - let (one, other) = ("Relinked {n} image; not in that folder: {names}", "Relinked {n} images; not in that folder: {names}"); - crate::i18n::msg_n(n as u64, one, other, &[("names", &missed.join(", "))]) - } + relinked_status_missed(v["relinked"].as_array().map_or(0, Vec::len), &missed) }); } @@ -426,7 +423,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { go_to(app, id); } if widgets::icon_button_enabled(ui, "link", tl!("Relink…"), false, !sel.is_empty() && can_pick(app), 24.0).clicked() { - relink(app, ids(&sel, |_| true)); + crate::menus::invoke(app, "ui.relinkDialog", json!({ "ids": ids(&sel, |_| true) })); } if widgets::icon_button_enabled(ui, "link-2-off", tl!("Break Link"), false, !linked.is_empty(), 24.0).clicked() { break_link(app, linked.clone()); @@ -548,7 +545,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let any = !sel.is_empty(); let missing_any = list["missing"].as_u64().unwrap_or(0) > 0; if menu_item(ui, tl!("Relink…"), any && can_pick(app), false) { - relink(app, ids(&sel, |_| true)); + crate::menus::invoke(app, "ui.relinkDialog", json!({ "ids": ids(&sel, |_| true) })); } if menu_item(ui, tl!("Relink to Folder…"), (any || missing_any) && app.services.pick_folder.is_some(), false) { relink_to_folder(app, ids(&sel, |_| true)); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 8f3af21b0..3dd7da7d6 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -435,7 +435,7 @@ fn objects_on_path_section(app: &mut VectorcraftApp, ui: &mut Ui, oop: &ObjectsO egui::Grid::new("props-objects-on-path").num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| { dim_label(ui, tl!("Pivot:")); // 9 is no square: the groups' pivots differ. - if let Some(i) = widgets::reference_point(ui, oop.pivot.map_or(9, usize::from)) + if let Some(i) = widgets::reference_point_named(ui, oop.pivot.map_or(9, usize::from), tl!("Pivot:")) && oop.pivot.is_none_or(|p| usize::from(p) != i) { set(app, json!({ "pivot": i })); diff --git a/crates/ui-egui/src/panels/transform/tests.rs b/crates/ui-egui/src/panels/transform/tests.rs index e066dffe8..a945f9ec1 100644 --- a/crates/ui-egui/src/panels/transform/tests.rs +++ b/crates/ui-egui/src/panels/transform/tests.rs @@ -426,6 +426,42 @@ fn use_registration_point_for_symbol_follows_a_transformed_instance() { } } +/// The reference point locator is one tab stop: from the widget before it, two presses of Tab reach +/// X, the first stopping on the whole locator; → on the locator moves the shared reference point to +/// the middle of the right side. +#[test] +fn the_reference_point_locator_is_one_tab_stop_and_arrows_move_it() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "70 pt").center(), show); + let x = ctx.memory(|m| m.focused()); + assert!(x.is_some(), "the click put the focus in X"); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let mut press = |key| { + frame_events(&ctx, &mut app, vec![key], show); + frame_events(&ctx, &mut app, vec![], show); + ctx.memory(|m| m.focused()) + }; + let locator = press(key(egui::Key::Tab, egui::Modifiers::SHIFT)); + let rect = |id: Option| id.and_then(|id| ctx.read_response(id)).map(|r| r.rect); + let grid = rect(locator).unwrap_or(egui::Rect::NOTHING); + assert_eq!(grid.size(), egui::Vec2::splat(23.0), "Shift+Tab from X reaches the whole locator, not one of its squares"); + let before = press(key(egui::Key::Tab, egui::Modifiers::SHIFT)); + assert!(before.is_some() && rect(before).is_some_and(|r| !grid.contains_rect(r)), "a second Shift+Tab leaves the locator"); + assert_eq!(press(key(egui::Key::Tab, egui::Modifiers::NONE)), locator, "Tab from the widget before the locator reaches it"); + assert_eq!(press(key(egui::Key::Tab, egui::Modifiers::NONE)), x, "a second Tab reaches X"); + press(key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(ctx.data(|d| d.get_temp::(egui::Id::unique("refpt"))), None, "→ in X leaves the reference point alone"); + press(key(egui::Key::Tab, egui::Modifiers::SHIFT)); + press(key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(ctx.data(|d| d.get_temp::(egui::Id::unique("refpt"))), Some(5), "→ on the locator chose the middle of the right side"); +} + #[test] fn artboard_fields_keep_the_reference_point() { let r = Rect::new(0.0, 0.0, 300.0, 200.0); diff --git a/crates/ui-egui/src/plugin_runs.rs b/crates/ui-egui/src/plugin_runs.rs index 719eadc18..a1711ee8f 100644 --- a/crates/ui-egui/src/plugin_runs.rs +++ b/crates/ui-egui/src/plugin_runs.rs @@ -9,12 +9,18 @@ //! ([`FilterRun::hand_in`]), so the edit is one undo step like any run, unless the document or the //! plug-in changed meanwhile: then nothing changes and the status bar says to run it again. On the //! web (no threads) it runs at once ([`available`]). +//! +//! A filter of plug-in API level 2 may report how far it has got (`vc_progress`): the progress +//! window then shows a bar with the share done. Its Cancel also makes the filter's `vc_cancelled` +//! return 1, so a filter that checks it stops at once; one that doesn't stops at the next slice of +//! its budget. +use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError, channel}; use serde_json::{Value, json}; -use vectorcraft_doc::cancel::{self, CancelToken}; use vectorcraft_engine::EngineError; +use vectorcraft_engine::cmd::fileio::progress::{self, Progress}; use vectorcraft_engine::cmd::plugin::FilterRun; use crate::VectorcraftApp; @@ -34,8 +40,8 @@ pub struct Running { who: String, /// `plugin.run`'s params that apply its output: the objects it was given as `ids`. params: Value, - /// Its Cancel. - token: CancelToken, + /// How far it has got, as it reports it, and its Cancel. + pub progress: Arc, /// Continue in Background: the progress window no longer shows it. pub hidden: bool, done: Receiver, @@ -44,7 +50,7 @@ pub struct Running { impl Running { /// Does the progress window show it: neither hidden (Continue in Background) nor cancelled? fn shown(&self) -> bool { - !self.hidden && !self.token.is_cancelled() + !self.hidden && !self.progress.cancelled() } } @@ -72,12 +78,12 @@ pub fn start(app: &mut VectorcraftApp, p: Value) -> Result { let mut params = p; params["ids"] = json!(run.inputs().iter().map(|i| i.0).collect::>()); let (tx, done) = channel(); - let token = CancelToken::new(); - let stop = token.clone(); + let shown = Progress::new(); + let reporting = Arc::clone(&shown); std::thread::Builder::new() .name("vectorcraft-plugin".into()) .spawn(move || { - let output = vectorcraft_engine::guard::catch_panic(|| cancel::scope(&stop, None, || run.run())) + let output = vectorcraft_engine::guard::catch_panic(|| progress::scope(&reporting, || run.run())) .map_err(|msg| format!("internal error: {msg} (please report this bug)")) .and_then(|r| r.map_err(|e| e.to_string())); // The app may have quit meanwhile: nothing to hand it to. @@ -87,7 +93,7 @@ pub fn start(app: &mut VectorcraftApp, p: Value) -> Result { let label = format!("Running {who}…"); let runs = &mut app.plugin_runs; runs.next_id += 1; - runs.running.push(Running { id: runs.next_id, label: label.clone(), who, params, token, hidden: false, done }); + runs.running.push(Running { id: runs.next_id, label: label.clone(), who, params, progress: shown, hidden: false, done }); app.status(label.clone()); Ok(json!({ "background": true, "status": label })) } @@ -121,7 +127,7 @@ pub fn poll(app: &mut VectorcraftApp) { /// they were; else why it has none, in the status bar. fn finish(app: &mut VectorcraftApp, r: Running, done: Option) { // Cancel changes nothing, even when the run ended just before it. - if r.token.is_cancelled() { + if r.progress.cancelled() { app.status(EngineError::Cancelled.to_string()); return; } @@ -146,12 +152,12 @@ fn finish(app: &mut VectorcraftApp, r: Running, done: Option) { } } -/// The progress window's Cancel (also Esc): every heavy filter running stops at the next slice of -/// its budget and changes nothing → `{cancelled: n}`. +/// The progress window's Cancel (also Esc): every heavy filter running stops (at once when it checks +/// `vc_cancelled`, else at the next slice of its budget) and changes nothing → `{cancelled: n}`. pub fn cancel_all(app: &mut VectorcraftApp) -> Value { let mut n = 0; - for r in app.plugin_runs.running.iter().filter(|r| !r.token.is_cancelled()) { - r.token.cancel(); + for r in app.plugin_runs.running.iter().filter(|r| !r.progress.cancelled()) { + r.progress.cancel(); n += 1; } json!({ "cancelled": n }) @@ -170,7 +176,7 @@ pub fn hide_all(app: &mut VectorcraftApp) -> Value { /// Is a heavy filter running that hasn't been cancelled? pub fn any_running(app: &VectorcraftApp) -> bool { - app.plugin_runs.running.iter().any(|r| !r.token.is_cancelled()) + app.plugin_runs.running.iter().any(|r| !r.progress.cancelled()) } /// Is a heavy filter running that the progress window shows? @@ -195,7 +201,11 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } let heading = crate::i18n::msg(&first.label); - let pressed = export_progress::window(ctx, "plugin-run-progress", &heading, |ui| export_progress::busy(ui, tl!("The plug-in is computing…"))); + let reported = first.progress.fraction(); + let pressed = export_progress::window(ctx, "plugin-run-progress", &heading, |ui| match reported { + Some(share) => export_progress::share(ui, tl!("The plug-in is computing…"), share as f32), + None => export_progress::busy(ui, tl!("The plug-in is computing…")), + }); let id = match pressed { Some(Pressed::Cancel) => "plugin.cancelRuns", Some(Pressed::Hide) => "plugin.hideRuns", diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 406141b41..713cf5dd4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -11,10 +11,12 @@ use vectorcraft_tools::{Mods, SurroundingUnit, ToolKey}; use crate::VectorcraftApp; +mod held_tool; mod paste; mod presentation; mod typing; +pub(crate) use held_tool::{HeldTool, note_tool_use}; use paste::unclip_chords; pub(crate) use paste::{PasteChord, paste_command}; pub(crate) use presentation::presentation_step; @@ -186,7 +188,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } ctx.input(|i| crate::shortcut_editor::note_backtick_text(&i.events)); note_key_surfaces(app, ctx); - if !(ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT) { + let focused = ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT; + // A held tool shortcut that is let go returns to the tool active before it. + held_tool::follow(app, ctx, focused); + if !focused { crate::ime::controls_without_editor(app, ctx); return; } @@ -317,6 +322,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)); } } + held_tool::note_enter_dialog(app, k); } } if typing { @@ -480,26 +486,26 @@ fn remember_escape_pair(ctx: &egui::Context, key: Key, escape_pair: Option fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian, // Arabic, Russian, Greek…) the key's position stands in for what it types (#793). - // A text's key is the press that typed it ([`typing_presses`]). - let events: Vec<(String, Modifiers, Option)> = ctx.input(|i| { + // A text's key is the press that typed it ([`typing_presses`]), and the text repeats with it. + let events: Vec<(String, Modifiers, Option, bool)> = ctx.input(|i| { let typed_by = typing_presses(&i.events); i.events .iter() .zip(typed_by) .filter_map(|(e, by)| match e { egui::Event::Text(t) => { - let (physical, key) = match by.and_then(|k| i.events.get(k)) { - Some(egui::Event::Key { key, physical_key, .. }) => (*physical_key, Some(*key)), - _ => (None, None), + let (physical, key, repeat) = match by.and_then(|k| i.events.get(k)) { + Some(egui::Event::Key { key, physical_key, repeat, .. }) => (*physical_key, Some(*key), *repeat), + _ => (None, None, false), }; - Some((latin(t, physical), i.modifiers, key)) + Some((latin(t, physical), i.modifiers, key, repeat)) } _ => None, }) .collect() }); let keymap = crate::shortcut_editor::current(); - for (text, m, pressed) in events { + for (text, m, pressed, repeat) in events { if m.command || m.alt || m.ctrl { continue; } @@ -521,7 +527,7 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // tools. let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue }; match hit.id.strip_prefix("tool:") { - Some(tool) => app.select_tool(tool), + Some(tool) => held_tool::choose(app, ctx, tool, pressed, repeat), None if let Some((id, p)) = menu_target(hit) => crate::menus::invoke(app, &id, p), None if hit.params.is_null() => { let _ = app.run(&hit.id, json!({})); diff --git a/crates/ui-egui/src/shortcuts/held_tool.rs b/crates/ui-egui/src/shortcuts/held_tool.rs new file mode 100644 index 000000000..a3a809b96 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/held_tool.rs @@ -0,0 +1,154 @@ +//! A single-key tool shortcut held down (General › Hold a Tool Shortcut to Use the Tool +//! Temporarily, `holdToolKeys`): the tool it chose stays until the key is let go, and then the tool +//! active before comes back. A quick press chooses the tool for good. + +use super::{Key, VectorcraftApp}; + +/// A key let go this many seconds or more after its press returns to the tool active before it. +/// VectorCraft's own value. +pub(crate) const HOLD_SECONDS: f64 = 0.25; + +/// A tool shortcut's key held down since it chose its tool. +pub(crate) struct HeldTool { + /// The key that chose the tool. + key: Key, + /// The viewport that saw the press: other viewports do not see the key. + viewport: egui::ViewportId, + /// The tool the key chose. + tool: &'static str, + /// The tool to go back to. + previous: &'static str, + /// When the key went down (`egui::InputState::time`). + since: f64, + /// The tool took a drag, a click or a dialog while the key was down. + used: bool, + /// When a frame first saw the key let go. The decision can come in a later frame (after a + /// drag, a dialog or a press of the same frame), and it uses this time. + released: Option, +} + +/// Chooses `tool` for a press of its single-key shortcut `key` (`None`: no key press typed it). A +/// press that changes the tool, with the preference on, starts a hold. A hold started while another +/// key is held goes on top of it: letting go of the later key returns to the earlier key's tool, +/// and letting go of the earlier key then returns to the tool before both. With the preference on, +/// a key's repeats choose nothing: a key held down chooses its tool once. With it off, each repeat +/// chooses the tool again. +pub(super) fn choose(app: &mut VectorcraftApp, ctx: &egui::Context, tool: &str, key: Option, repeat: bool) { + if repeat && app.session.prefs.hold_tool_keys { + return; + } + let previous = app.session.tool_id(); + // `select_tool` ends the holds of a tool chosen any other way; the keys held stay held here. + let holds = std::mem::take(&mut app.held_tool); + app.select_tool(tool); + app.held_tool = holds; + let chosen = app.session.tool_id(); + if chosen == previous { + return; + } + let viewport = ctx.viewport_id(); + let (down, now) = key.map_or((false, 0.0), |k| ctx.input(|i| (i.key_down(k), i.time))); + match key { + // A key let go in the frame that pressed it was a quick press. + Some(key) if down && app.session.prefs.hold_tool_keys && chosen == tool => { + app.held_tool.retain(|h| h.viewport == viewport && h.key != key); + app.held_tool.push(HeldTool { key, viewport, tool: chosen, previous, since: now, used: false, released: None }); + } + _ => app.held_tool.clear(), + } +} + +/// A press that reaches the active tool, a drag of the Zoom, Hand or Rotate View tool, or the +/// tool's dialog opened with Enter, uses the tool of the key held last when that tool is active, +/// also when that and the key's release come between two frames of [`follow`]. +pub(crate) fn note_tool_use(app: &mut VectorcraftApp) { + let active = app.session.tool_id(); + if let Some(held) = app.held_tool.last_mut().filter(|h| h.tool == active) { + held.used = true; + } +} + +/// After the tool takes `key`: a dialog that Enter left open is the tool's, so it uses the tool. +pub(super) fn note_enter_dialog(app: &mut VectorcraftApp, key: Key) { + if key == Key::Enter && app.ui.dialog.is_some() { + note_tool_use(app); + } +} + +/// Once per frame of the viewport that saw the press: when the key held last is let go, goes back +/// to the tool active before it, unless the press was quick and the tool was not used. A key let +/// go during a drag, with the button held or with a dialog open, goes back once that ends. Choosing +/// another tool, a quick press, turning the preference off or the window losing the keyboard ends +/// every hold without going back. A frame of another viewport that has the keyboard also ends them. +pub(super) fn follow(app: &mut VectorcraftApp, ctx: &egui::Context, focused: bool) { + // Every key let go is timed in the frame that first sees it up, under the key held last too. + let (viewport, time) = (ctx.viewport_id(), ctx.input(|i| i.time)); + for held in app.held_tool.iter_mut().filter(|h| h.viewport == viewport && h.released.is_none()) { + if !ctx.input(|i| i.key_down(held.key)) { + held.released = Some(time); + } + } + // Each pass looks at the key held last; one that goes back hands over to the key under it. + loop { + let gesture = app.session.tool_busy() || app.session.pointer_held(); + // A dialog, or a canvas drag that does not reach the tool (a pan, zoom or turn of the view), + // defers the decision. + let dialog = app.ui.dialog.is_some() || crate::canvas::dragging(ctx); + let active = app.session.tool_id(); + let on = app.session.prefs.hold_tool_keys; + let Some(held) = app.held_tool.last_mut() else { return }; + if !on { + app.held_tool.clear(); + return; + } + if held.viewport != ctx.viewport_id() { + // This viewport has the keyboard, so the viewport that saw the press lost it. + if focused { + app.held_tool.clear(); + } + return; + } + if !focused { + app.held_tool.clear(); + return; + } + let (down, now) = ctx.input(|i| (i.key_down(held.key), i.time)); + if gesture || dialog { + // A gesture of the held tool with the key down uses the tool; a gesture lent to a + // selection tool does not. A dialog uses it only when Enter opened it, and a view drag + // when it is the held tool's ([`note_tool_use`]). + held.used |= gesture && down && active == held.tool; + return; + } + if active != held.tool { + app.held_tool.clear(); + return; + } + if down { + return; + } + // The canvas takes a pointer press of the same frame, and the tool an Enter press, after + // this: the decision waits for the next frame, measured from this one. + let pressed = ctx.input(|i| { + i.events + .iter() + .any(|e| matches!(e, egui::Event::PointerButton { pressed: true, .. } | egui::Event::Key { key: Key::Enter, pressed: true, .. })) + }); + let released = held.released.unwrap_or(now); + if pressed && released == now { + ctx.request_repaint(); + return; + } + let back = held.used || released - held.since >= HOLD_SECONDS; + let previous = held.previous; + if !back { + app.held_tool.clear(); + return; + } + app.held_tool.pop(); + app.return_to_tool(previous); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/shortcuts/held_tool/tests.rs b/crates/ui-egui/src/shortcuts/held_tool/tests.rs new file mode 100644 index 000000000..e2217b822 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/held_tool/tests.rs @@ -0,0 +1,462 @@ +use serde_json::json; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use super::super::handle; +use super::*; + +/// A keyboard on one context, so egui keeps the keys held down from frame to frame and marks a +/// held key's later presses as repeats, with the frame times the test sets. +struct Keyboard { + ctx: egui::Context, +} + +impl Keyboard { + fn new() -> Self { + Self { ctx: egui::Context::default() } + } + + /// One frame at `time` seconds delivering `events`; with `field`, a text field that takes the + /// keyboard is drawn after the shortcuts. + fn frame(&self, app: &mut VectorcraftApp, time: f64, events: Vec, field: Option<&mut String>) { + self.frame_with_focus(app, time, events, field, true); + } + + /// [`Self::frame`], with the window having the keyboard (`focused`) or not. + fn frame_with_focus(&self, app: &mut VectorcraftApp, time: f64, events: Vec, mut field: Option<&mut String>, focused: bool) { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let mut out = self.ctx.run_ui(egui::RawInput { events, screen_rect, time: Some(time), focused, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + handle(app, ui.ctx()); + if let Some(text) = field.as_deref_mut() { + ui.add(egui::TextEdit::singleline(text).id(egui::Id::unique("held-tool-field"))).request_focus(); + } + }); + out.textures_delta.clear(); + } + + /// One frame at `time` seconds delivering `events`, with the canvas drawn after the shortcuts. + fn on_canvas(&self, app: &mut VectorcraftApp, time: f64, events: Vec) { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let mut out = self.ctx.run_ui(egui::RawInput { events, screen_rect, time: Some(time), focused: true, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + handle(app, ui.ctx()); + crate::canvas::show(app, ui); + }); + out.textures_delta.clear(); + } + + /// `key` pressed (or repeated while it is down) at `time`, typing `letter`. + fn press(&self, app: &mut VectorcraftApp, time: f64, key: Key, letter: &str) { + let press = egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + self.frame(app, time, vec![press, egui::Event::Text(letter.into())], None); + } + + fn release(&self, app: &mut VectorcraftApp, time: f64, key: Key) { + let release = egui::Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + self.frame(app, time, vec![release], None); + } + + fn press_s(&self, app: &mut VectorcraftApp, time: f64) { + self.press(app, time, Key::S, "s"); + } + + fn release_s(&self, app: &mut VectorcraftApp, time: f64) { + self.release(app, time, Key::S); + } +} + +/// A document with a selected rectangle and the Pen tool chosen. +fn pen_app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + crate::shortcut_editor::sync(&app.ui); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap(); + app.select_tool("pen"); + app +} + +/// S held from 0 s, its repeats at `repeats`, let go at `release`. +fn hold_s(app: &mut VectorcraftApp, keys: &Keyboard, repeats: &[f64], release: f64) { + keys.press_s(app, 0.0); + assert_eq!(app.session.tool_id(), "scale", "the press chooses Scale"); + for &t in repeats { + keys.press_s(app, t); + assert_eq!(app.session.tool_id(), "scale", "a repeat at {t} s keeps Scale"); + } + keys.release_s(app, release); +} + +/// A quick press of S chooses Scale for good. +#[test] +fn a_quick_press_of_a_tool_key_chooses_the_tool_for_good() { + let mut app = pen_app(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[], HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "scale"); + keys.frame(&mut app, 5.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "Scale stays chosen"); + assert!(app.held_tool.is_empty()); +} + +/// A press and release of S in one frame is a quick press, also when the next frame comes long +/// after it. +#[test] +fn a_press_and_release_in_one_frame_chooses_the_tool_for_good() { + let mut app = pen_app(); + let keys = Keyboard::new(); + let key = |pressed| egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.0, vec![key(true), egui::Event::Text("s".into()), key(false)], None); + keys.frame(&mut app, 5.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// S held past the threshold, with its key repeats, and let go returns to the Pen. +#[test] +fn a_held_tool_key_returns_to_the_previous_tool_when_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[0.1, 0.2, 0.3, 0.4], 0.5); + assert_eq!(app.session.tool_id(), "pen"); + assert!(app.held_tool.is_empty()); +} + +/// S held from the Pen and then V held: letting go of V returns to Scale, and letting go of S +/// then returns to the Pen. +#[test] +fn overlapping_held_tool_keys_return_one_at_a_time() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.1, Key::V, "v"); + assert_eq!(app.session.tool_id(), "selection"); + keys.release(&mut app, 0.4, Key::V); + assert_eq!(app.session.tool_id(), "scale", "letting go of V returns to Scale while S is down"); + keys.release_s(&mut app, 0.6); + assert_eq!(app.session.tool_id(), "pen", "letting go of S returns to the Pen"); + assert!(app.held_tool.is_empty()); +} + +/// S let go during a Scale drag returns to the Pen once the drag ends, also when the key was let go +/// before the threshold. +#[test] +fn a_tool_key_let_go_during_a_drag_returns_after_the_drag() { + for release in [HOLD_SECONDS / 2.0, 1.0] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 260.0, 190.0), view).unwrap(); + assert!(app.session.tool_busy(), "Scale is dragging"); + keys.frame(&mut app, release / 2.0, vec![], None); + keys.release_s(&mut app, release); + assert_eq!(app.session.tool_id(), "scale", "let go at {release} s: Scale stays during the drag"); + keys.frame(&mut app, release + 0.1, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "let go at {release} s: still dragging"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 260.0, 190.0), view).unwrap(); + keys.frame(&mut app, release + 0.2, vec![], None); + assert_eq!(app.session.tool_id(), "pen", "let go at {release} s: the Pen is back after the drag"); + } +} + +/// A click on the canvas between two frames while S is down uses Scale, so letting go of S before +/// the threshold returns to the Pen. +#[test] +fn a_click_between_frames_counts_as_using_the_held_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.release_s(&mut app, HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "pen"); +} + +/// A Cmd/Ctrl click while S is down goes to the selection tool lent to Scale, so it does not use +/// Scale: letting go of S before the threshold keeps Scale, whether or not a frame comes during +/// the click. +#[test] +fn a_click_lent_to_a_selection_tool_does_not_use_the_held_tool() { + for frame_during_click in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let mods = vectorcraft_tools::Mods { cmd: true, ..Default::default() }; + assert!(app.session.lent_tool_id(&mods).is_some(), "Scale lends a selection tool to a Cmd press"); + let view = app.view_info(); + let event = |kind| PointerEvent { mods, ..PointerEvent::new(kind, 150.0, 125.0) }; + crate::canvas::dispatch(&mut app, &event(PointerKind::Down), view); + if frame_during_click { + keys.frame(&mut app, HOLD_SECONDS / 4.0, vec![], None); + } + crate::canvas::dispatch(&mut app, &event(PointerKind::Up), view); + keys.release_s(&mut app, HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "scale", "frame during the click: {frame_during_click}"); + assert!(app.held_tool.is_empty()); + } +} + +/// A pan with the Hand tool while H is down uses the Hand: letting go of H quickly after the pan +/// returns to the Pen, and letting go of H during the pan returns once the pan ends. +#[test] +fn a_view_tool_drag_counts_as_using_the_held_tool() { + for let_go_during_pan in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.on_canvas(&mut app, 0.0, vec![]); + let at = app.canvas_rect.expect("the canvas was drawn").center(); + let to = at + egui::vec2(30.0, 20.0); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let key = |pressed| egui::Event::Key { key: Key::H, physical_key: Some(Key::H), pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.on_canvas(&mut app, 0.01, vec![egui::Event::PointerMoved(at), key(true), egui::Event::Text("h".into())]); + assert_eq!(app.session.tool_id(), "hand", "let go during the pan: {let_go_during_pan}"); + keys.on_canvas(&mut app, 0.02, vec![button(at, true)]); + keys.on_canvas(&mut app, 0.03, vec![egui::Event::PointerMoved(to)]); + assert!(crate::canvas::dragging(&keys.ctx), "the Hand pans"); + if let_go_during_pan { + keys.on_canvas(&mut app, 0.05, vec![key(false)]); + assert_eq!(app.session.tool_id(), "hand", "the Hand stays during the pan"); + keys.on_canvas(&mut app, 0.06, vec![button(to, false)]); + } else { + keys.on_canvas(&mut app, 0.05, vec![button(to, false)]); + keys.on_canvas(&mut app, 0.06, vec![key(false)]); + } + keys.on_canvas(&mut app, 0.07, vec![]); + assert_eq!(app.session.tool_id(), "pen", "let go during the pan: {let_go_during_pan}"); + assert!(app.held_tool.is_empty()); + } +} + +/// A pointer press in the frame that lets go of S: a press that reaches Scale on the canvas returns +/// to the Pen, and a press elsewhere (no canvas press follows) keeps Scale, also when the next frame +/// comes late. +#[test] +fn a_press_in_the_frame_that_lets_go_waits_for_the_canvas() { + for canvas in [true, false] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let at = egui::pos2(150.0, 125.0); + let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.1, vec![egui::Event::PointerMoved(at), press, release], None); + assert_eq!(app.session.tool_id(), "scale", "canvas {canvas}: no decision before the canvas"); + if canvas { + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + } + keys.frame(&mut app, 2.0, vec![], None); + assert_eq!(app.session.tool_id(), if canvas { "pen" } else { "scale" }, "canvas {canvas}"); + } +} + +/// S and then V pressed quickly, both let go in the frame of a canvas click with the Selection tool: +/// V was used, so Scale comes back, and S was a quick press, so Scale stays, also when the next +/// frame comes late. +#[test] +fn keys_let_go_together_are_timed_in_the_frame_that_lets_them_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.02, Key::V, "v"); + let at = egui::pos2(150.0, 125.0); + let up = |key| egui::Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.05, vec![egui::Event::PointerMoved(at), press, up(Key::V), up(Key::S)], None); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.frame(&mut app, 2.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); + assert!(app.held_tool.is_empty()); +} + +/// With S held and then V held, a click with the Selection tool uses Selection only: V let go +/// quickly returns to Scale, and S let go quickly then keeps Scale. +#[test] +fn a_click_uses_only_the_tool_of_the_key_held_last() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.02, Key::V, "v"); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.release(&mut app, 0.05, Key::V); + assert_eq!(app.session.tool_id(), "scale", "V was used, so Scale is back"); + keys.release_s(&mut app, 0.1); + assert_eq!(app.session.tool_id(), "scale", "Scale was not used, so it stays"); +} + +/// Enter with S down opens the Scale dialog, and with V down the Move dialog; the key let go quickly +/// while the dialog is open returns to the Pen once the dialog closes. +#[test] +fn a_dialog_opened_during_the_hold_counts_as_using_the_tool() { + for (key, letter, tool, dialog) in [(Key::S, "s", "scale", "scale"), (Key::V, "v", "selection", "move")] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press(&mut app, 0.0, key, letter); + let enter = + egui::Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.05, vec![enter], None); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialog), "Enter opens the {dialog} dialog"); + keys.release(&mut app, 0.1, key); + assert_eq!(app.session.tool_id(), tool, "{tool} stays while its dialog is open"); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 0.15, vec![], None); + assert_eq!(app.session.tool_id(), "pen", "after the {dialog} dialog"); + } +} + +/// Enter pressed in the frame that lets go of S after the threshold opens the Scale dialog, and +/// Scale stays until the dialog closes. +#[test] +fn enter_in_the_frame_that_lets_go_reaches_the_held_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let enter = egui::Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 1.0, vec![release, enter], None); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("scale"), "Enter opens the Scale dialog"); + keys.frame(&mut app, 1.1, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "Scale stays while its dialog is open"); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 1.2, vec![], None); + assert_eq!(app.session.tool_id(), "pen"); +} + +/// Another dialog (Preferences) opened while S is down and S let go quickly while it is open: Scale +/// was not used, so it stays once the dialog closes. +#[test] +fn another_dialog_opened_during_the_hold_does_not_use_the_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + crate::prefs_dialog::open(&mut app, None); + keys.frame(&mut app, 0.05, vec![], None); + keys.release_s(&mut app, 0.1); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 0.15, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// S typed and held in a text field types its letter and chooses no tool. +#[test] +fn a_tool_key_held_in_a_text_field_types_and_keeps_the_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + let mut text = String::new(); + keys.frame(&mut app, 0.0, vec![], Some(&mut text)); + let press = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.1, vec![press.clone(), egui::Event::Text("s".into())], Some(&mut text)); + keys.frame(&mut app, 0.6, vec![press, egui::Event::Text("s".into())], Some(&mut text)); + keys.frame(&mut app, 1.0, vec![release], Some(&mut text)); + assert_eq!(text, "ss"); + assert_eq!(app.session.tool_id(), "pen"); + assert!(app.held_tool.is_empty()); +} + +/// With General › Hold a Tool Shortcut to Use the Tool Temporarily off, a held S chooses Scale for +/// good, as a quick press does. +#[test] +fn with_the_preference_off_a_held_tool_key_chooses_the_tool_for_good() { + let mut app = pen_app(); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[0.1, 0.2, 0.3, 0.4], 0.5); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// With the preference off, each repeat of a held S chooses Scale again, as before: a flyout opened +/// in between closes. +#[test] +fn with_the_preference_off_each_repeat_chooses_the_tool_again() { + let mut app = pen_app(); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.ui.flyout = Some(0); + keys.press_s(&mut app, 0.3); + assert_eq!(app.ui.flyout, None, "the repeat chose Scale again"); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// A hold whose viewport stopped drawing ends when another viewport has the keyboard or the +/// preference is turned off, and stays while the other viewport does not have the keyboard. +#[test] +fn a_hold_of_another_viewport_ends_when_this_one_has_the_keyboard() { + let other = egui::ViewportId::from_hash_of("held-tool-other"); + let hold = || HeldTool { key: Key::S, viewport: other, tool: "scale", previous: "pen", since: 0.0, used: false, released: None }; + let mut app = pen_app(); + app.select_tool("scale"); + let keys = Keyboard::new(); + app.held_tool.push(hold()); + keys.frame_with_focus(&mut app, 0.5, vec![], None, false); + assert_eq!(app.held_tool.len(), 1, "a viewport without the keyboard leaves the hold alone"); + keys.frame(&mut app, 1.0, vec![], None); + assert!(app.held_tool.is_empty(), "a viewport with the keyboard ends the hold"); + assert_eq!(app.session.tool_id(), "scale"); + app.held_tool.push(hold()); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + keys.frame_with_focus(&mut app, 1.5, vec![], None, false); + assert!(app.held_tool.is_empty(), "turning the preference off ends the hold"); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// Scale chosen again on the toolbar while S is held stays chosen when S is let go. +#[test] +fn the_held_tool_chosen_again_stays_when_the_key_is_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.select_tool("scale"); + // A tool chosen closes an open flyout: the repeats of S, still down, choose nothing. + app.ui.flyout = Some(0); + keys.press_s(&mut app, 0.3); + assert_eq!(app.ui.flyout, Some(0), "a repeat chooses no tool"); + keys.release_s(&mut app, 0.5); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// The window losing the keyboard while S is held, also during a drag, ends the hold: Scale stays. +#[test] +fn losing_the_keyboard_ends_the_hold() { + for drag in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + if drag { + app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 260.0, 190.0), view).unwrap(); + } + keys.frame_with_focus(&mut app, 0.3, vec![egui::Event::WindowFocused(false)], None, false); + if drag { + app.session.pointer(&PointerEvent::new(PointerKind::Up, 260.0, 190.0), view).unwrap(); + } + keys.frame(&mut app, 0.5, vec![egui::Event::WindowFocused(true)], None); + keys.release_s(&mut app, 0.6); + assert_eq!(app.session.tool_id(), "scale", "drag {drag}: Scale stays"); + } +} + +/// Another tool chosen while S is held stays chosen when S is let go. +#[test] +fn a_tool_chosen_during_the_hold_stays_when_the_key_is_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.select_tool("rectangle"); + keys.frame(&mut app, 0.3, vec![], None); + keys.release_s(&mut app, 0.5); + assert_eq!(app.session.tool_id(), "rectangle"); +} diff --git a/crates/ui-egui/src/tests/plugin_runs.rs b/crates/ui-egui/src/tests/plugin_runs.rs index a9b299eda..194ec133d 100644 --- a/crates/ui-egui/src/tests/plugin_runs.rs +++ b/crates/ui-egui/src/tests/plugin_runs.rs @@ -173,6 +173,46 @@ fn continue_in_background_hides_the_progress_window() { assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string()); } +/// A heavy filter of plug-in API level 2 that reports its progress gets a bar in its progress window, +/// with the share it reported, clamped; Cancel makes its `vc_cancelled` return 1 and it stops, +/// changing nothing. +#[test] +fn a_filter_that_reports_progress_shows_a_bar_and_sees_cancel() { + let mut app = app(); + let id = "test.ui.heavy.progress"; + let manifest = + format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","budget":"heavy","api":2,"permissions":["doc.read","doc.write","progress"]}}"#); + let first = "(call $vc_progress (i64.const 5000) (i64.const 1000)) (call $vc_progress (i64.const 250) (i64.const 1000)) + (block $stop (loop $l (br_if $stop (call $vc_cancelled)) (br $l)))"; + let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::reporting(&manifest, first, r#"{"objects":[]}"#)).unwrap()); + app.run("plugin.install", json!({"dataBase64": data, "allowHeavy": true, "grant": ["doc.read", "doc.write", "progress"]})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + app.run("plugin.dialog", json!({"id": id})).unwrap(); + // Waits for the run's thread to report the share (a handshake: the thread never stops before + // it is cancelled, and a run that ended leaves the loop). + loop { + plugin_runs::poll(&mut app); + match app.plugin_runs.running.first() { + Some(r) if r.progress.counts() != (250, 1000) => std::thread::yield_now(), + _ => break, + } + } + assert_eq!(app.plugin_runs.running.first().map(|r| r.progress.counts()), Some((250, 1000)), "the second report, after the first was clamped"); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + progress(&mut app, &ctx, 0.0); + progress(&mut app, &ctx, 1.0); + let shown = progress(&mut app, &ctx, 1.1); + assert!(shown.contains("The plug-in is computing…") && shown.contains("25 %"), "{shown}"); + assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1); + while !app.plugin_runs.running.is_empty() { + plugin_runs::poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.doc().unwrap().doc, doc); + assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string()); +} + /// `CRAFT_UI_HEAVY_FILTER_WASM=` writes a heavy test filter to install in the app /// (`order-paths-test-filter.wasm`): it computes until it is cancelled or its budget runs out, so /// its progress window stays up for a picture. diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index 3bd693afa..d5e10d8d1 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -119,3 +119,83 @@ fn plugin_effects_open_the_effect_dialog_with_their_schema() { app.run("effect.dialog", json!({"effect": "plugin.test.ui.fx", "index": 0})).unwrap(); assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("mode"), Some(&Value::from("b"))); } + +/// A filter `id` named `name` whose manifest lists `permissions` (a JSON list), at a version with +/// a bidirectional override, a tab and a bell in it. +fn asking(id: &str, name: &str, permissions: &str) -> Vec { + let manifest = json!({"id": id, "name": name, "version": "3.0\u{202E}\t\u{0007}", "kind": "filter"}).to_string(); + let manifest = format!(r#"{},"permissions":{permissions}}}"#, manifest.trim_end_matches('}')); + wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap() +} + +/// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash +/// and each permission in plain words, with hidden characters from the manifest escaped. Cancel +/// installs nothing, OK installs it; installing it again with the same permissions doesn't ask, and +/// one that asks for another does. +#[test] +fn installing_a_plugin_that_lists_permissions_asks_first() { + let mut app = app(); + let id = "test.ui.permissions"; + // U+202E would show the text after it reversed, and U+2066 isolates it; a manifest's name may + // hold them, though not control characters, which its version may. + let name = "Tidy\u{202E}fdp.exe\u{2066}"; + let bytes = asking(id, name, r#"["doc.read","doc.write"]"#); + let data = vectorcraft_format::base64_encode(&bytes); + let r = app.run("ui.installPlugin", json!({"dataBase64": data, "name": "tidy.wasm"})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.str("message"), "Install “Tidy\\u{202E}fdp.exe\\u{2066}”, which asks for these permissions?"); + let hash = vectorcraft_plugins::permissions::sha256_hex(&bytes); + assert_eq!(d.fields.get("__params").map(|p| p["hash"].clone()), Some(json!(hash)), "OK installs the module shown"); + let detail = d.str("detail"); + assert_eq!( + detail, + format!( + "ID: {id}\nVersion: 3.0\\u{{202E}}\\u{{0009}}\\u{{0007}}\nFile SHA-256: {hash}\n\n• Read the objects it is given\n• Change, add and delete objects in the document" + ) + ); + assert!(!detail.chars().chain(d.str("message").chars()).any(|c| matches!(c, '\u{202E}' | '\u{2066}' | '\t' | '\u{0007}'))); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "not before the user agrees"); + dialogs::cancel(&mut app); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "Cancel installs nothing"); + // File › Open asks too, and OK installs it with what it lists granted. + io::open_bytes(&mut app, "tidy.wasm", &bytes, None).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND)); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(vectorcraft_plugins::registry::get(id).is_some()); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(hash)); + // Again with the same permissions: installed without asking. + let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#); + app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + assert!(app.ui.dialog.is_none(), "the same permissions don't ask again"); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); + // One that asks for another permission asks again. + let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + assert!(app.ui.dialog.as_ref().unwrap().str("detail").ends_with("• Keep data of its own with the document")); + dialogs::cancel(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "Cancel keeps the one installed"); + // OK keeps the `replace` it was asked with: `false` leaves the installed one in place. + app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more), "replace": false})).unwrap(); + let _ = dialogs::confirm(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "replace: false is kept"); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} + +/// A plug-in of API level 2 that lists `progress` names it in plain words when it asks. +#[test] +fn installing_a_plugin_that_reports_progress_says_so() { + let mut app = app(); + let id = "test.ui.permissions.progress"; + let manifest = format!(r#"{{"id":"{id}","name":"Count","kind":"filter","api":2,"permissions":["doc.read","progress"]}}"#); + let bytes = wat::parse_str(tpl::reporting(&manifest, "(call $vc_progress (i64.const 1) (i64.const 2))", r#"{"objects":[]}"#)).unwrap(); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let detail = app.ui.dialog.as_ref().unwrap().str("detail"); + assert!(detail.ends_with("• Read the objects it is given\n• Show its progress, see Cancel and write log lines"), "{detail}"); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.permissions.len()), Some(2)); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 689f28ac5..e4319809b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -23,6 +23,7 @@ mod toggles; pub use blend::{ TransparencyEdit, angle_dial, blend_dropdown, blend_param, blend_param_dropdown, blend_separator_before, opacity_blend, reference_point, + reference_point_named, }; pub use buttons::{ SPLIT_ARROW, flat_button, flat_button_fit, icon_button, icon_button_enabled, menu_item, menu_item_name, paint_icon_button, primary_button, @@ -239,6 +240,121 @@ pub(crate) mod accessibility_tests { let chosen = if i == 4 { Toggled::True } else { Toggled::False }; assert_eq!(role_and_state(&tree, label), (Role::RadioButton, Some(chosen)), "{label}"); } + let grid = named(&tree, "Reference Point"); + assert_eq!((grid.role(), grid.value()), (Role::RadioGroup, Some("Center")), "the grid is a radio group showing the current point"); + } + + /// Screen readers find the nine squares inside the Reference Point radio group. + #[test] + fn the_reference_point_group_holds_its_squares() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + let mut nodes = vec![]; + for time in [0.0, 0.1] { + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { + reference_point(ui, 0); + }); + out.textures_delta.clear(); + nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default(); + } + let node = |id: &egui::accesskit::NodeId| nodes.iter().find(|(n, _)| n == id).map(|(_, n)| n); + let Some((group, _)) = nodes.iter().find(|(_, n)| n.label() == Some("Reference Point")) else { panic!("no Reference Point group") }; + let mut inside = vec![]; + let mut todo = vec![*group]; + while let Some(id) = todo.pop() { + for child in node(&id).map(|n| n.children()).unwrap_or_default() { + inside.extend(node(child).and_then(|n| n.label()).map(str::to_owned)); + todo.push(*child); + } + } + for label in ["Top Left", "Top", "Top Right", "Left", "Center", "Right", "Bottom Left", "Bottom", "Bottom Right"] { + assert!(inside.iter().any(|l| l == label), "{label} is in the group: {inside:?}"); + } + } + + /// A frame of a button, the reference point grid at `point` and another button, after `events`; + /// a new point from the grid replaces `point`. Returns the two buttons' ids. + fn grid_frame(ctx: &egui::Context, events: Vec, point: &mut usize) -> (egui::Id, egui::Id) { + let mut ids = (egui::Id::NULL, egui::Id::NULL); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + ids.0 = ui.button("Before").id; + if let Some(i) = reference_point(ui, *point) { + *point = i; + } + ids.1 = ui.button("After").id; + }); + // No renderer takes the font atlas updates in a test; dropping them unapplied panics. + out.textures_delta.clear(); + ids + } + + /// Tab stops once on the reference point grid, not on each of its nine squares. + #[test] + fn the_reference_point_grid_is_one_tab_stop() { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut point = 4; + let mut ids = (egui::Id::NULL, egui::Id::NULL); + let stops = tab_stops(&ctx, 3, |events| ids = grid_frame(&ctx, events, &mut point)); + assert_eq!(stops.len(), 3, "{stops:?}"); + assert_eq!(stops[0], ids.0, "the first Tab reaches the button before the grid"); + let size = ctx.read_response(stops[1]).map(|r| r.rect.size()); + assert_eq!(size, Some(egui::Vec2::splat(23.0)), "the second Tab reaches the whole grid, not one of its squares"); + assert_eq!(stops[2], ids.1, "the third Tab leaves the grid for the button after it"); + assert_eq!(point, 4, "Tab leaves the point as it was"); + } + + /// While the grid has the focus, the arrow keys move the point one square, stop at the edges and + /// keep the focus on the grid. + #[test] + fn arrow_keys_move_the_reference_point() { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut point = 4; + let stops = tab_stops(&ctx, 2, |events| { + grid_frame(&ctx, events, &mut point); + }); + let grid = stops[1]; + let arrow = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + for (key, want) in [ + (egui::Key::ArrowRight, 5), + (egui::Key::ArrowUp, 2), + (egui::Key::ArrowRight, 2), + (egui::Key::ArrowUp, 2), + (egui::Key::ArrowLeft, 1), + (egui::Key::ArrowDown, 4), + (egui::Key::ArrowDown, 7), + (egui::Key::ArrowLeft, 6), + (egui::Key::ArrowDown, 6), + ] { + grid_frame(&ctx, vec![arrow(key)], &mut point); + grid_frame(&ctx, vec![], &mut point); + assert_eq!(point, want, "{key:?}"); + assert_eq!(ctx.memory(|m| m.focused()), Some(grid), "{key:?} keeps the focus on the grid"); + } + // From no point, an arrow moves from the center. + point = 9; + grid_frame(&ctx, vec![arrow(egui::Key::ArrowLeft)], &mut point); + grid_frame(&ctx, vec![], &mut point); + assert_eq!(point, 3, "← from no point chose the middle of the left side"); + assert_eq!(ctx.memory(|m| m.focused()), Some(grid), "← from no point keeps the focus on the grid"); + } + + /// A locator under another label takes that label, without its colon, as the group's name. + #[test] + fn a_named_reference_point_group_takes_its_label() { + // Each locator gets its own id scope, so their ids differ. + let tree = tree(|ui| { + for (point, label) in [(4, "Pivot:"), (0, "Origine :"), (8, "基準点:")] { + ui.push_id(label, |ui| reference_point_named(ui, point, label)); + } + }); + for (label, value) in [("Pivot", "Center"), ("Origine", "Top Left"), ("基準点", "Bottom Right")] { + let grid = named(&tree, label); + assert_eq!((grid.role(), grid.value()), (Role::RadioGroup, Some(value)), "{label}"); + } } #[test] diff --git a/crates/ui-egui/src/widgets/blend.rs b/crates/ui-egui/src/widgets/blend.rs index 040c8ac9f..6edfab334 100644 --- a/crates/ui-egui/src/widgets/blend.rs +++ b/crates/ui-egui/src/widgets/blend.rs @@ -94,14 +94,29 @@ pub fn opacity_blend( } /// The 3×3 reference point locator: nine squares, the eight outer ones joined by a line around -/// the box (the centre one stands alone), the current one filled. Returns a new index when clicked. +/// the box (the centre one stands alone), the current one filled. Returns a new index when a square +/// is clicked or an arrow key moves the point. +/// +/// The grid is one Tab stop. While it has the keyboard focus it shows an outline, and ←, →, ↑ and +/// ↓ move the point one square, stopping at the edges; from no point (`current` 9 or more) they +/// move from the center. Screen readers get a radio group named Reference Point, with the current +/// point as its value, holding the nine squares as radio buttons. pub fn reference_point(ui: &mut Ui, current: usize) -> Option { + reference_point_named(ui, current, tl!("Reference Point")) +} + +/// [`reference_point`] for a locator that a label other than Reference Point names, such as +/// `Pivot:`: screen readers name the radio group after `label`, without its final colon. +pub fn reference_point_named(ui: &mut Ui, current: usize, label: &str) -> Option { + let name = label.trim_end().trim_end_matches([':', ':']).trim_end(); let t = Tokens::get(ui.ctx()); // 5 pt squares with 4 pt gaps; the origin is snapped to whole points so the 1 pt lines through // the squares' centres stay crisp. let (sq, step) = (5.0, 9.0); let size = 2.0 * step + sq; let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + // The grid takes the focus; the squares take clicks only, so Tab does not stop on each one. + let grid = ui.interact(rect, ui.scope_id().with("refpt"), Sense::focusable_noninteractive()); let o = rect.min.round(); let centre = |i: usize| o + Vec2::new(sq / 2.0 + (i % 3) as f32 * step, sq / 2.0 + (i / 3) as f32 * step); let line = Stroke::new(1.0, t.text_dim); @@ -119,10 +134,22 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option { tl!("Bottom"), tl!("Bottom Right"), ]; + grid.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::Role::RadioGroup, ui.is_enabled(), name); + info.current_text_value = names.get(current).map(|n| n.to_string()); + info + }); + if grid.has_focus() && ui.is_enabled() { + out = reference_point_keys(ui, grid.id, current); + } + // The squares go in a child `Ui` whose screen-reader parent is the group, so that the group + // holds them. + let scope = ui.scope_id(); + let squares = ui.new_child(egui::UiBuilder::new().max_rect(rect).accessibility_parent(grid.id)); for (i, name) in names.into_iter().enumerate() { let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); - let resp = ui.interact(r.expand(2.0), ui.scope_id().with(("refpt", i)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), i == current, name)); + let resp = squares.interact(r.expand(2.0), scope.with(("refpt", i)), Sense::CLICK); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, squares.is_enabled(), i == current, name)); if resp.clicked() { out = Some(i); } @@ -133,9 +160,48 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option { ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside); } } + if grid.has_focus() { + ui.painter().rect_stroke(rect.expand(2.0), 2.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } out } +/// The point this frame's arrow keys move the focused reference point grid `id` to from +/// `current`, when it differs. +fn reference_point_keys(ui: &Ui, id: egui::Id, current: usize) -> Option { + use egui::{Event, Key}; + // The arrows move the point and do not move the focus. egui keeps the filter only when the grid + // also had the focus in the frame before, and applies it from the next frame; until then, an + // arrow the grid takes cancels the focus move that egui set at the start of the frame. + let filter = egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }; + ui.memory_mut(|m| m.set_focus_lock_filter(id, filter)); + let mut at = if current < 9 { current } else { 4 }; + let mut arrows = false; + ui.input_mut(|i| { + i.events.retain(|e| { + let Event::Key { key, pressed: true, modifiers, .. } = e else { return true }; + if !modifiers.is_none() { + return true; + } + let (col, row) = (at % 3, at / 3); + at = match key { + Key::ArrowLeft => row * 3 + col.saturating_sub(1), + Key::ArrowRight => row * 3 + (col + 1).min(2), + Key::ArrowUp => row.saturating_sub(1) * 3 + col, + Key::ArrowDown => (row + 1).min(2) * 3 + col, + _ => return true, + }; + arrows = true; + false + }); + }); + if !arrows { + return None; + } + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + (at != current).then_some(at) +} + /// An angle dial `size` points across: a circle with a hand from its centre at `angle` (degrees, /// counter-clockwise from 3 o'clock). Pressing or dragging points the hand at the pointer, in whole /// degrees (-179..180), Shift in 45° steps. Returns the new angle. diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index 4b2b31806..d7590ff3b 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -519,7 +519,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Saves the current bars, toolbar and panel layout.")).color(t.text_dim).size(11.0)); } ui.add_space(16.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // As wide as the fields 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; } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0a9bfd9f0..4e8408cd5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -685,7 +685,16 @@ art too large at that scale) keeps the dialog open. Placement Options: the Links panel's flyout (or `ui.placementOptionsDialog {ids?}` for agents) opens the `placementOptions` dialog for the selected images (fields `ids`, `preserve`: `transforms`/`bounds`/`fileDimensions`/ `fit`/`fill`, `align`: `topLeft` … `bottomRight`, `clip`). `ui.dialog.confirm` runs `links.placementOptions` with them as -one undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's +one undo step. + +Relink: the Links panel's Relink… button and flyout item (or `ui.relinkDialog {ids?}` for agents) open the `relink` +dialog for the selected images and placed documents (fields `ids`, `links`: `[{name, ids}]`, one row per linked file, +`scope`: `each`/`selected`/`all`/`missingInFolder`, default `selected`, `path` and `paths`). With nothing selected and +no `ids`, it fails with "select the images to relink, or pass ids". `ui.dialog.set {field: "path", value}` then `ui.dialog.confirm` runs +`links.relink {ids, path, scope}` as one undo step; without `path` a file is picked. With `scope: "each"`, each confirm +takes `path` (or picks a file) for the next row of `links`, adds it to `paths` and keeps the dialog open, returning +`{next}` (the next row's name); the last row's confirm runs `links.relink {scope: "each", paths}` for every row as one +undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's `links.info`; `links.editOriginal` and `links.reveal` (Edit › Edit Original, Show in Folder) hand the linked file to the system and return `{path}`. Edit Original opens only a regular file whose extension is a format `document.open` reads, and answers a control request with an error unless the file is a placed document (which opens in a tab). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7579eceab..805bbc63a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -187,7 +187,9 @@ private editing data. VectorCraft: block in vertical type opens unmarked, and two blocks with nothing between them open as one block. Its upright Roman characters (`Tv`) open with Standard Vertical Roman Alignment. VectorCraft's own layout rule also sets a run of two or three half-width digits across the column in vertical type, inside or outside a block and with - either `Tv` value. + either `Tv` value. Its binding punctuation (`Tg`) opens as Burasagari's Force setting when on and as no + Burasagari when off, which is VectorCraft's own mapping; the repeat character, line breaking (`TG`) and + Japanese layout rules (`Xu`, `Tu`) are not read. Legacy `.ai` type set in rendering modes 4 to 7 (`Tr`, text used as a mask) is a clipping path of its clipping group, as a `W` path is, and the mode lasts into later text objects. Type in linked areas, in wraparound text or in a compound path doesn't clip, with a warning. A `Tr` operand that is not a whole number @@ -229,6 +231,8 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. + ## Format by format | Format | Illustrator | Ours: open/place | Ours: save/export | Fidelity and tests | diff --git a/docs/mcp.md b/docs/mcp.md index 96c0f72dc..9220b1c62 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -380,8 +380,14 @@ explicit `ids`, which stand in for the selection, so they work with nothing sele `ids?|id?` (or `id?, ids?: … (instead of id)`), `id` is `ids` of one object: `{"id": 7}` does what `{"ids": [7]}` does, with or without a selection, and an `id` or `ids` that is malformed (not a non-negative integer) or names no object is refused under the command's name, never replaced by the selection (`effect.apply {"id": "warp.arc"}` -without `effect` names the effect). A command that leaves out an object selected inside a selected group also -leaves it out when `ids` list it together with a group that holds it. +without `effect` names the effect). Other commands that act on `id` or else the selection, the highlighted layer row +or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseToLayers`, `graphicStyle.new`, +`graphicStyle.redefine`, `charStyle.new`, `charStyle.apply`, `charStyle.redefine` and their `paraStyle.*` +counterparts, `image.info`, `links.info`, `links.goTo`, `links.unembed`, `shaper.release`, `shaper.expand`, +`transparency.editOpacityMask`, `transparency.viewOpacityMask`, `symbol.edit`, the `object.blend.spine.*` commands +and `path.curvature`) refuse an `id` that is not a non-negative integer (`"abc"`, `"5"`, `true`) with an error and +change nothing; an omitted or null `id` keeps the default. A command that leaves out an object selected inside a +selected group also leaves it out when `ids` list it together with a group that holds it. Paint values (`fill`, `stroke`) accept `"#rrggbb"`, `"none"`, `[r,g,b]` (0..1), `{"c","m","y","k"}`, `{"gray"}`, or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"}`). @@ -3631,7 +3637,11 @@ control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home s off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by default: on, a click away from a panel popped out of the icon column puts it away), `displayPrintSize` (off by default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills -96 screen points, an inch at the system's reference density whatever the display scaling). `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features +96 screen points, an inch at the system's reference density whatever the display scaling). The tool shortcut +preference `holdToolKeys` also needs the desktop app. It is on by default: a single-key tool shortcut held for 0.25 s +or more, or held while its tool is used, chooses the tool until the key is let go and then returns to the tool active +before, after any drag in progress; a quicker press chooses the tool for good. With it off, every press and every key +repeat chooses the tool. `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features substituted — ligatures, contextual forms — on screen; Document Setup's Highlight Substituted Glyphs stays separate). `highlightSubstitutedFonts` (on by default: the canvas paints pink behind text drawn in a substitute for a missing font, in every document). `blackOnScreen` @@ -4370,6 +4380,14 @@ path isn't followed) when it isn't. The desktop app also checks the active docum comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`, `missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. +`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other object in the layers linked to the same file as +one of them; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in +`path`'s folder, and lists the names not there in `notFound` (with `copy`, only missing images are looked for); `each` takes `paths: [{ids, path}]` in place of `ids`, +`path` and `folder` and relinks each link to its own file. An unknown scope, `paths` without `each`, `each` with `ids`, +`path` or `folder`, an entry without `path` or `ids`, an id in two entries and `missingInFolder` with `folder` are +errors, and nothing is relinked. Each call is one undo step. The Links panel's Relink… opens the `relink` dialog +(`ui.relinkDialog`) with a Replace menu of these four choices. A PNG, JPEG, +another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. `links.update`, and `document.open` when it reads a modified file, also read the file again for the images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content (by default also for those that only that art shows); they have no ids, so the results do not list them. Saving a document also reads it @@ -5511,8 +5529,8 @@ Force) of the selected type (or `ids`): an East Asian comma or full stop ending 、。) hangs outside the frame, Regular only when it doesn't fit, Force always (the rest of the line then fills the measure). Closing brackets and Latin punctuation don't hang; point type, right-to-left and hyphenated lines are left as they are. The hanging mark is as wide as its punctuation spacing makes it (`mojikumi`). New type (the Type tools, -`text.create`) takes `standard`; documents from before it and imported text keep `none`, and `none` isn't saved. One -undo step. +`text.create`) takes `standard`; documents from before it and imported text keep `none`, except legacy `.ai` type +with binding punctuation (`Tg`) on, which opens as `forced`. `none` isn't saved. One undo step. ```json {"name":"run_command","arguments":{"command":"text.setFormat","params":{"burasagari":"forced"}}} @@ -6076,7 +6094,18 @@ that no built-in format has. A failing plug-in returns an error and leaves the d no file). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its `plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run` runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it. -`plugin.install` rejects a `replace` or `allowHeavy` value that is not a boolean. `prefs.set` returns an error and +A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) +installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass +the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns +an error naming the permissions needed. The grant is kept with the module's hash; a module +with the same id that lists no new permission installs without `grant`, and `plugin.remove` forgets the grant. A +filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. +A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write +log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log +under its id. A module that imports anything else, or imports these without `api` 2 and `progress`, is refused by +`plugin.install` and `plugin.inspect` with an error naming the import. +`plugin.install` rejects a `replace` or `allowHeavy` value that is not a boolean, a `grant` that is not a list of +permission names, and a `hash` that is not the module's SHA-256. `prefs.set` returns an error and changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty value or the folder the preference already holds, and the web build does not check folders. @@ -6372,6 +6401,13 @@ envelopes, the path for a top-object envelope); the content keeps the envelope's opacity mask. Type inside an envelope distorts as its glyph outlines, run by run in each run's paint, on the canvas and in every export (SVG, PDF, EPS, EMF/WMF, DXF). +An envelope maps type from the bounds of its glyph outlines, underlines and strikethroughs instead of its laid-out +box (ascent to descent, or an area's frame) when the type is on its own, in groups and layers, or used as a clipping +path. Inline graphics in the type count by their boxes. Rotated type and type in perspective map from the bounds of +their outlines as they are drawn. `object.envelope.makeWithMesh`, `object.envelope.makeWithTopObject` and +`object.envelope.resetWithMesh` with `maintainShape: false` use those bounds. Type that draws nothing keeps its +laid-out box, and so does type inside any other object, such as a blend, a repeat or a symbol instance. This is +VectorCraft's own rule, and envelopes of type in saved files redraw by it. Everything inside bends with the envelope: images as a raster mesh warp (cut into pieces by Fidelity, each a clipped image under its own affine map; SVG shares the pixels between the pieces), symbol instances as their symbol's art, and with Distort Appearance strokes (as their filled outlines) and geometry effects; with it, Distort Linear Gradients diff --git a/docs/plugins.md b/docs/plugins.md index e1fcce813..27a7b0f91 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -23,13 +23,13 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters)) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. | -| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have. | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | +| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. | -| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so is a heavy filter (nobody was asked). | +| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). | Live effects are applied like any effect: `effect.apply {"effect": "plugin.", "params": {…}}`, edited with `effect.setParams`, listed by `effect.list` (menu `Effect › Plug-ins`) and baked by Object › Expand Appearance. @@ -47,7 +47,8 @@ heading and buttons in view. Exporters are formats of **File › Export › Expo Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is picked, and its Export button writes the file. **File › Open** of a `.wasm` file installs a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the -background ([below](#heavy-filters)). +background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed +([below](#permissions)). **Preference folder.** Edit › Preferences › Plug-ins & Scratch Disks › *Additional Plug-ins Folder*: when set, every `*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; @@ -66,7 +67,7 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Limit | Default | On violation | |---|---|---| -| Host imports | none: no WASI, file system, network, clock or randomness | module rejected at install | +| Host imports | none at API level 1; at level 2 only the three [host imports](#host-imports-api-level-2): no WASI, file system, network, clock or randomness | module rejected at install, naming the import | | Module size | 32 MiB | rejected | | Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | | Output | 64 MiB of JSON | `Limit("output size")` | @@ -101,6 +102,52 @@ much there is only once the run has ended. - `plugin.run` itself runs a heavy filter where it is called, as it runs any filter: an MCP client waits for it, and its `notifications/cancelled` stops it. +### Permissions + +A manifest may list what the plug-in may do in `permissions`. `doc.read`, `doc.write` and `state` follow the +capability model proposed for PhotoCraft plug-ins +([storytold/photocraft#662](https://github.com/storytold/photocraft/issues/662)); the other names are VectorCraft's own: + +| Permission | Kinds | What it allows | +|---|---|---| +| `doc.read` | all | Read the objects it is given. | +| `doc.write` | filter | Change, add and delete objects in the document. | +| `state` | filter | Keep data of its own with the document (`documentData`). | +| `effect.render` | effect | Change how the objects its effect is applied to are drawn. | +| `export.write` | exporter | Write the document as a file in its own format. | +| `progress` | all | Show its progress, see Cancel and write log lines through the [host imports](#host-imports-api-level-2). Needs `api` 2. | + +- A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter + `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and + `export.write`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. A filter whose list is + empty may do none of these, and also installs without a prompt. +- An effect or an exporter that lists permissions lists both of its implied ones. A filter may list any of its three. + Any kind may add `progress`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply + to the kind, more than 16 names, `progress` with an `api` below 2, or one of the names kept for a later plug-in API + (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. +- **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of + the file and each permission in plain words, and install it once the user clicks OK, if the file still has the + SHA-256 shown; Cancel installs nothing. The + names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. + `plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent + passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the + `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. +- The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the + `Plug-ins` folder. Installing a module with the same id that lists no new permission needs no new approval; one that + lists more asks again. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a + permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or + removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than + 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept + in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, + a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload` + install only plug-ins whose permissions were granted, and report the others. +- The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its + `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the + document is refused. A refused output leaves the document as it was. +- `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host + imports](#host-imports-api-level-2). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`. +- Parameter declarations are unchanged by permissions (`params`, below). + Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that @@ -121,8 +168,9 @@ geometry: a plug-in runs again only when one of them changes. ## ABI v1 A plug-in is a core WebAssembly module (MVP + the usual post-MVP features: bulk memory, sign extension, multi-value, -saturating conversions; no SIMD, no memory64) with **no imports** and these exports. Integers are `i32` unless noted; -pointers are offsets into the module's memory. +saturating conversions; no SIMD, no memory64) with these exports and no imports other than the [host +imports](#host-imports-api-level-2) of API level 2. Integers are `i32` unless noted; pointers are offsets into the +module's memory. | Export | Signature | Meaning | |---|---|---| @@ -132,6 +180,34 @@ pointers are offsets into the module's memory. | `vc_alloc` | `(size) -> ptr` | A block of `size` bytes the host may write, or `0` on failure. Never freed: each run gets a fresh instance. | | `vc_run` | `(input, input_len, params, params_len) -> i64` | Runs the plug-in on the input JSON with the parameters JSON; returns `(len << 32) \| ptr` of the output JSON, or a negative error code. | +### Host imports (API level 2) + +A plug-in whose manifest gives `"api": 2` and lists the `progress` [permission](#permissions) may import these +functions from module `env` (the module a Rust or C toolchain gives `extern` functions by default). `vc_abi_version` +still returns `1`. + +| Import | Signature | Meaning | +|---|---|---| +| `vc_progress` | `(done: i64, total: i64)` | `done` of `total` steps are finished; `total` 0 or less when 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, ptr, len) -> i32` | Writes the UTF-8 text at `ptr` as a log line of `level` (0 error, 1 warning, 2 information, 3 debug). Returns `0` when written, `1` when the run's log budget is spent and the line was dropped, `-1` when the text doesn't lie inside the module's memory, `-2` when it is not UTF-8, `-3` for another level. | + +- The host checks the imports when the module is installed: an import it doesn't offer, one with another signature, + and one the manifest doesn't allow (an `api` below 2, or no `progress` permission) refuse the module, and the error + names the import. +- A filter's or an exporter's progress is shown as a share: a heavy filter's progress window and the export's progress + window draw a bar with a percentage, and Cancel there makes `vc_cancelled` return `1`. A plug-in that checks it can + stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that + ignores it is still stopped by the instruction and time budgets. With a known total, a report is passed on only when + it reaches another tenth of a percent; with an unknown total, each change of `done` is passed on. After 2,000 reports + in a run, one change in 1,000 is passed on, and so is the first report after the total becomes known or unknown. A + live effect's progress is not shown, since it is drawn as part of something else. +- Log lines go to the app's log (target `vectorcraft_plugins`) as `: `, with control and + bidirectional characters escaped. The whole text must be UTF-8. A line shows its first 1,024 bytes, as at most 1,024 + characters with the escapes, and ends with `…` when it is cut. A run writes at most 200 lines and 32 KiB of shown + text, and the host reads at most 1 MiB of log text in a run; past any of these, one "log truncated" line is written + and the rest are dropped. + ### Manifest ```json @@ -159,6 +235,8 @@ pointers are offsets into the module's memory. the kind when the plug-in is installed. - `budget` (filters only): `"light"` (the default) or `"heavy"`, for work that takes minutes ([heavy filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget. +- `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names + ([permissions](#permissions)); without it, what its kind implies. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id. @@ -325,12 +403,12 @@ crates/plugins/example/build.sh # cargo build --release --target wasm32-unk The script also refreshes `crates/plugins/tests/fixtures/desaturate.wasm`, the test fixture built from that source. The crate is not a workspace member. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, -WAT…), as long as the module has no imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never +WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, which the tests build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters (with a base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, and `wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows in a -live effect's geometry. +live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host functions. ## Performance @@ -340,9 +418,8 @@ per change of their input, parameters or paints (results are cached), not per fr ## Not in v1 -- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host - callbacks (a plug-in reporting progress or asking whether it was cancelled), importers (plug-in formats that open - files). +- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, importers + (plug-in formats that open files). - Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or symbols. - Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 723fc673a..31dc47a26 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -662,6 +662,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. + - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). Nothing runs scripts yet. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. @@ -749,7 +750,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Pattern Options panel's Name field and the name fields that a double-click opens in the Layers, Artboards and Asset Export panels take Shift+Enter (Shift+Return) the same way: it applies the name and keeps the field active with the name selected. - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels, and so do the links that label a field (the Control bar's Stroke: and Opacity:, the Properties panel's Stroke), which a click still follows to their panel (#4088). Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. - In the Transform panel, the Properties panel's Transform section and the Control bar, Alt+Enter (Option+Return) applies a value to a copy of the selection and selects the copy, in one undo step. With anchor points selected with Direct Selection it applies the value to them, as Enter does. Ctrl/Cmd+Enter in W or H applies the value and scales the selection proportionally with the link off. With the Artboard tool, it scales the active artboard proportionally. A percentage typed alone in W or H (`50%`) is that share of the size the field shows, and the link and Ctrl/Cmd+Enter keep the proportions as with a length (#2401). - - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. + - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. As VectorCraft's own keyboard rule, the locator is one tab stop in the Control bar popover, the Transform panel, the Properties panel and the dialogs, the Arrange Tools dialogs included. While it has the focus it shows an outline, and ←, →, ↑ and ↓ move the reference point one square, stopping at the edges (`the_reference_point_grid_is_one_tab_stop`, `arrow_keys_move_the_reference_point`, `the_reference_point_locator_is_one_tab_stop_and_arrows_move_it`). - In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected. - While an opacity mask is edited, Exit Isolation Mode (Esc, the isolation bar's back arrow, the Control bar's button, and the Layers panel menu's and canvas context menu's Exit Isolation Mode) leaves mask editing as Stop Editing Opacity Mask does, in one undo step: the mask keeps the edits, and the masked object is selected. While a pattern is edited, the Exit Isolation Mode item of the Layers panel menu and of the canvas context menu saves the pattern and leaves pattern editing, as Done does. - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). A field's up/down spinner, the chevron that opens its presets (Stroke Weight, font size, Rotate, Export for Screens' scale) and the Transparency row's opacity slider chevron are not tab stops either. Alt+↓ with no other modifier in a field with presets opens them with the keyboard focus on the chosen one, instead of stepping the field down; Enter picks the focused preset without pressing a dialog's OK, and after a pick or Escape the field has the keyboard focus again (`tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron`, `tab_goes_from_the_opacity_field_past_its_slider_chevron`, `alt_down_opens_a_fields_presets_for_the_keyboard`, `escape_closes_a_fields_presets_and_returns_to_the_field`). @@ -804,6 +805,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Edit › Keyboard Shortcuts lists each of the Align panel's Align Objects, Distribute Objects and Distribute Spacing buttons under Align, with no default shortcut, as the reference app's [release notes](https://web.archive.org/web/20261006170350/https://helpx.adobe.com/illustrator/desktop/new-features/release-notes.html) describe for its Align and Distribute commands. A shortcut set there runs what a click on the button runs, with the Align To choice, the key object and the Distribute Spacing value (`align.horizontalLeft` and the other `align.*` UI commands). The command palette lists `select.key` as Cancel Key Object, and a pick cancels the key object (#541). Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). + Commands that act on `id` or else the selection, the highlighted layer row or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseToLayers`, the graphic, character and paragraph style commands, `image.info`, the `links.*` and `shaper.*` commands that take an `id`, opacity mask editing and viewing, `symbol.edit`, the blend spine commands and `path.curvature`) refuse an `id` that is not an object id (`"abc"`, `"5"`, `true`) and change nothing; `effect.apply` with the effect given as `id` still applies it to the selection. Agents read the key object: `select.key` returns it, and `document.inspect` and the `ui.pointer` and `pointer_gesture` replies report it as `key`. `select.key` with an id that isn't a selected top-level object returns an error and leaves the key unchanged. The Select menu's Same commands, its Object commands that select art, Next Object Above and Below, saved selections and the Magic Wand (#2655) select only objects that Select All selects and objects inside them: nothing hidden or locked, nothing in a hidden or locked group, layer or sublayer, nothing on a template layer, and in isolation mode nothing outside the isolated group or layer. In isolation mode Select All, All on Active Artboard and Inverse select nothing while the isolated group or layer, or a group or layer it is in, is hidden, locked or a template layer. Select ▸ Object ▸ Bristle Brush Strokes selects the objects whose topmost stroke has a bristle brush, whatever the brush is named, and leaves out objects whose brush is of another type, also when its name has "Bristle" in it. @@ -874,7 +876,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Composition's leading-space reduction stays along the line when a character rotates. An opening bracket trimmed by Line-end Punctuation Half Width Mojikumi keeps that placement under Character Rotation; its turning point follows the composition adjustment, and removing the rotation keeps the trim. Scores and estimates are unchanged. A character rotated near or at a quarter turn follows a Touch Type drag across its line as well as along it: baseline shift moves a character up from its line at any rotation, and the character turns about its shifted pen position. Right-to-left characters can also move along their line when a preceding logical character is on that line. Positive `text.touchType` `dx` follows reading order, toward the left in horizontal right-to-left text; the first logical character still moves only up and down. These direction rules are VectorCraft's choices; the public Touch Type page describes moving a character without specifying this case. Scores and estimates are unchanged. - - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. + - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. A single-key tool shortcut held for 0.25 s or more, or held while its tool is used, chooses that tool only until the key is let go, and the tool active before comes back (after a drag in progress ends); a quicker press chooses the tool for good. This is VectorCraft's own rule and its own threshold, and General › Hold a Tool Shortcut to Use the Tool Temporarily (`holdToolKeys`, on by default) turns it off. Space held while the Zoom tool drags a marquee moves the marquee and keeps its size ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). When Space is let go, the drag sizes the marquee again from where it moved to. A Space+Shift drag rotates the view whatever the tool, as a Rotate View tool drag does, and Shift does not round the angle to 15°. - **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color. @@ -1117,6 +1119,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, five gradient libraries and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. + Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. The prompt for deleting several swatches and groups uses the interface language's plural form for the count. Evidence: `deleting_several_swatches_uses_each_count_form` in `crates/ui-egui/src/panels/swatches/tests.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels. @@ -1278,6 +1281,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.rs`). A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). @@ -1345,6 +1349,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before. - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. + - The Links panel's Relink… opens a Relink dialog whose Replace menu picks which links take the new file: Selected Instances (the selected images, as before), All Instances (every image linked to the same file), Missing Links from This Folder (then every other missing link whose file name is in the new file's folder) and Each Link Separately (a file for each selected link in turn). Each relinks as one undo step (`links.relink {scope}`, `ui.relinkDialog`). Relink to Folder… stays as it is. - Linked files inside symbols, graph designs, patterns and opacity masks are saved with their paths relative to the document, as linked files in the layers are, so they are found after the document and its files move to another folder together; on open each of those links takes the path where its file was found. Update Links, and opening with Update Links: Automatically, read a modified file again for that art too, and saving a document refreshes its placed copies inside symbols and patterns of the other open documents. Evidence: `links_outside_the_layers_find_their_files_after_the_folder_moves`, `update_links_reads_files_again_inside_symbols_patterns_and_masks`, `saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns`. - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing. - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed. @@ -1436,6 +1441,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Type drawn as glyph outlines:** under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, flattened with Convert All Text to Outlines, and inside an envelope, type keeps its underline and strikethrough, the weight and dashes of its character strokes when it is scaled, and the placement of its gradients and patterns, as the canvas draws it. A group's or layer's own fills and strokes also paint the underline and strikethrough of the type in it. +- **Envelopes of type map from the glyphs:** an envelope maps point type, area type, type in groups and layers and type used as a clipping path from the bounds of the glyph outlines, underlines and strikethroughs instead of the type's laid-out box (ascent to descent, or the area's frame), with inline graphics counted by their boxes; rotated type and type in perspective map from their outlines as drawn. Make with Mesh, Make with Top Object and Reset with Mesh without Maintain Shape use those bounds. Type that draws nothing keeps its laid-out box, and so does type inside any other object, such as a blend, a repeat or a symbol instance. This is VectorCraft's own rule; envelopes of type made in VectorCraft and in saved files redraw by it. Tests: `crates/engine/src/tests/envelope.rs`, and `tests/envelope_ink.rs` in `vectorcraft-eps`, `vectorcraft-render` and `vectorcraft-effects`. Scores and hours unchanged. + - **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified. - **Smart Blur modes:** Effect › Blur › Smart Blur… has a Mode menu with Normal, Edge Only and Overlay Edge (`blurMode`: `normal`, `edgeOnly` or `overlayEdge`; an effect saved without it draws as Normal). Edge Only draws the edges in white on black, and Overlay Edge draws them in white over the Normal blur. A pixel is on an edge when one of the pixels the search reads within Radius of it differs from it by more than Threshold in a channel and its channels, alpha included, add up to at least that pixel's. The search reads neighbors as the Normal blur does, kept within Radius, and always the four nearest pixels, so a color step gives a line about as wide as Radius, and at least one pixel wide, on the side with the larger sum (lines on both sides when the sums are equal). The white and the black keep each pixel's coverage. Behavior reference: [Summary of effects](https://helpx.adobe.com/illustrator/using/summary-effects.html). @@ -1471,12 +1478,16 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Envelope names given as another type:** `object.envelope.makeWithWarp`, `resetWithWarp` and `options` refuse a `style` or `orientation` other than text or null, such as `5`, and an `orientation` other than `horizontal` or `vertical`; `object.envelope.options` refuses such a `preserveShape`. `object.envelope.options` checks the warp keys with no envelope or only mesh envelopes selected, too. Each leaves the document, the options new envelopes get and the undo history as they were. - **Exporter plug-ins (G17):** a WebAssembly plug-in of kind `exporter` adds a write-only file format. File › Export › Export As lists it after the built-in formats, with Use Artboards greyed out (the plug-in writes the whole document) and its parameters in a generated dialog once the file is picked; `document.export` writes it by its format id, `plugin.`, or by one of its extensions, and built-in formats keep theirs. The plug-in is given the native file's JSON (large images without their data) and `_context` (`mode`, `extension`, `name`), with 10 G instructions plus 4,000 per byte of input and 300 s, and Background Export's Cancel stops it between two slices of that budget. A decline or invalid output writes nothing; its warnings come back with the export's. Regressions: `crates/engine/tests/plugin_export.rs`, `crates/plugins/tests/abi.rs`, `crates/ui-egui/src/dialogs/tests_plugin_export.rs`. Scores and hours unchanged. +- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged. - **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/effect.rs`. Scores and hours unchanged. - **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. +- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. +- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3117a7af1..6b8bda8d1 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -63,6 +63,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("plugins", Class::Layer(2)), ("affinity", Class::Standalone), ("scriptcheck", Class::Standalone), + ("js", Class::Standalone), ]; /// Explicit orderings *within* a layer (earlier may be used by later). diff --git a/xtask/src/main.rs b/xtask/src/main.rs index de6ff5815..334910732 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -14,6 +14,7 @@ mod pdf_deps; mod pngsuite; mod sha256; mod stats; +mod test262; mod version; mod web_fonts; @@ -41,6 +42,8 @@ commands: ico ... pack PNGs into a Windows .ico (used by packaging/icons.sh) stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + test262 fetch the pinned ECMAScript conformance suite into corpus/test262 (sha256 + verified) and parse its ES5 subset with vectorcraft-js, printing the counts web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk post_build hook; a no-op without CRAFT_FONTS_DIR) "; @@ -64,6 +67,7 @@ fn main() -> ExitCode { Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("test262") => test262::run_suite(), Some("web-fonts") => web_fonts::run(&root()), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); @@ -333,6 +337,8 @@ Tests that use a corpus skip cleanly when it is absent. corpus/raw/ raw.pixls.us samples, CC0 (optional) corpus/affinity/ public Affinity documents (CC0/MIT/Apache-2.0) at pinned commits, sha256 verified: oracles for the Affinity reader. Fetch: cargo xtask corpus --affinity + corpus/test262/ the ECMAScript conformance suite (BSD-3-Clause) at a pinned commit, sha256 + verified: the JavaScript parser's ES5 subset. Fetch and run: cargo xtask test262 ", corpus.display() ); diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs new file mode 100644 index 000000000..ab390538b --- /dev/null +++ b/xtask/src/test262.rs @@ -0,0 +1,73 @@ +//! `cargo xtask test262`: the official ECMAScript conformance suite (https://github.com/tc39/test262, +//! BSD-3-Clause) at a pinned commit, fetched as GitHub's archive of that commit, checked against +//! its SHA-256 and size, and unpacked into `corpus/test262/` (gitignored, never committed). It then +//! runs `vectorcraft-js`'s `test262` test, which parses the ES5 parse subset and prints its counts. +//! +//! Moving the pin: change [`COMMIT`], run the command once with the new commit to see the +//! archive's SHA-256 in the error, check the archive, and update [`SHA256`]. + +use std::path::Path; +use std::process::Command; + +use crate::{cargo, root, run, sha256}; + +/// The tc39/test262 commit the suite is read at. +const COMMIT: &str = "d53ebc8b1931d9f1541e43d59650776e4b766248"; + +/// The SHA-256 of GitHub's `tar.gz` archive of [`COMMIT`]. +const SHA256: &str = "6db8566044d1a3f5a68aac87806e09b2fe5d21f5869f3a74cfd8b7236ebaf5c8"; + +/// The largest archive accepted (the pinned one is 9.6 MB). +const MAX_BYTES: u64 = 64 << 20; + +pub fn run_suite() -> Result<(), String> { + let dir = fetch()?; + let mut c = cargo(); + c.env("VECTORCRAFT_TEST262_DIR", &dir).args(["test", "-p", "vectorcraft-js", "--release", "--test", "test262", "--", "--nocapture"]); + run(c, &format!("cargo test -p vectorcraft-js --release --test test262 -- --nocapture (VECTORCRAFT_TEST262_DIR={})", dir.display())) +} + +/// Fetches and unpacks the suite unless it is there already, and returns its folder. +fn fetch() -> Result { + let base = root().join("corpus").join("test262"); + let dir = base.join(format!("test262-{COMMIT}")); + let marker = dir.join(".vectorcraft-sha256"); + if std::fs::read_to_string(&marker).is_ok_and(|s| s.trim() == SHA256) { + println!("test262 {COMMIT} is in {}", dir.display()); + return Ok(dir); + } + std::fs::create_dir_all(&base).map_err(|e| format!("create {}: {e}", base.display()))?; + let archive = base.join(format!("test262-{COMMIT}.tar.gz")); + if !verified(&archive)? { + let url = format!("https://codeload.github.com/tc39/test262/tar.gz/{COMMIT}"); + let mut curl = Command::new("curl"); + curl.args(["-fsSL", "--retry", "3", "-A", "VectorCraft-dev", "--max-filesize", &MAX_BYTES.to_string(), "-o"]).arg(&archive).arg(&url); + run(curl, &format!("curl {url}"))?; + if !verified(&archive)? { + let got = std::fs::read(&archive).map(|b| sha256::hex(&b)).unwrap_or_default(); + let _ = std::fs::remove_file(&archive); + return Err(format!("test262 archive: sha256 {got} does not match the pinned {SHA256}")); + } + } + if dir.exists() { + std::fs::remove_dir_all(&dir).map_err(|e| format!("remove {}: {e}", dir.display()))?; + } + let mut tar = Command::new("tar"); + tar.arg("-xzf").arg(&archive).arg("-C").arg(&base); + run(tar, &format!("tar -xzf {}", archive.display()))?; + if !dir.join("test").join("language").is_dir() { + return Err(format!("{} has no test/language folder after unpacking", dir.display())); + } + std::fs::write(&marker, SHA256).map_err(|e| format!("{}: {e}", marker.display()))?; + println!("test262 {COMMIT} unpacked into {}", dir.display()); + Ok(dir) +} + +/// The archive is there, within the size limit, with the pinned SHA-256. +fn verified(path: &Path) -> Result { + let Ok(meta) = std::fs::metadata(path) else { return Ok(false) }; + if meta.len() > MAX_BYTES { + return Err(format!("{} is {} bytes, over the {MAX_BYTES}-byte limit", path.display(), meta.len())); + } + Ok(std::fs::read(path).is_ok_and(|b| sha256::hex(&b) == SHA256)) +}