diff --git a/Cargo.lock b/Cargo.lock index f9c3dacaa3..a4eac55700 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280" dependencies = [ "chrono", "dunce", @@ -1108,7 +1108,7 @@ dependencies = [ [[package]] name = "craft-vectorcraft-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280" dependencies = [ "flate2", "serde", diff --git a/Cargo.toml b/Cargo.toml index 29f43866c2..ec55a218f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,11 +21,11 @@ craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637 # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] } # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. -craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280" } # craft-update: app-specific pretend versions apply to startup and manual checks. # Native Affinity documents (craft-libs#914). craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 9a92efb37e..0d4deca36f 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -248,6 +248,7 @@ fn with_stores(mut h: Headless) -> Headless { prefs_dir::attach_plugin_store(&mut h.session); prefs_dir::attach_pdf_preset_store(&mut h.session); prefs_dir::attach_custom_dictionary(&mut h.session); + prefs_dir::attach_arrowheads(&mut h.session); h } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 0e146bd2bb..c2b40e37e6 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -488,6 +488,8 @@ fn main() -> std::process::ExitCode { prefs_dir::attach_pdf_preset_store(&mut app.session); // The custom dictionary's words, kept in their own file next to the preferences. prefs_dir::attach_custom_dictionary(&mut app.session); + // The custom arrowheads, kept in their own file next to the preferences. + prefs_dir::attach_arrowheads(&mut app.session); // Fit the window to its monitor, or put it back where it was (still hidden). let winit_window = cc.winit_window().cloned(); if let Some(w) = &winit_window { diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 775ef73841..11afcfe121 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -89,6 +89,14 @@ pub fn attach_custom_dictionary(session: &mut vectorcraft_engine::Session) { } } +/// Keep the custom arrowheads (Stroke panel menu › Edit Arrowheads…) in the `Arrowheads.vectorcraft` +/// file next to the settings, and list the heads it holds. +pub fn attach_arrowheads(session: &mut vectorcraft_engine::Session) { + if let Some(path) = store_dir(vectorcraft_engine::cmd::arrowheads::FILE_NAME) { + session.attach_arrowheads_file(path.to_string_lossy().to_string()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index b1aa9727ba..ba0f5fc013 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -10,7 +10,7 @@ mod stroke_style; pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds}; pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile}; -pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; +pub use stroke_style::{ArrowAlign, Arrowhead, CustomArrowhead, Dash, LineCap, LineJoin, StrokeAlign}; /// One entry of the appearance stack. // A stroke takes 328 bytes and a fill 104; an appearance holds a few of each. diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs index 923275999b..f8a58758d8 100644 --- a/crates/doc/src/appearance/layers.rs +++ b/crates/doc/src/appearance/layers.rs @@ -193,8 +193,10 @@ impl StrokeLayer { /// How far the start (`end == false`) or end arrowhead can reach from its end point (0 /// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap. pub fn head_reach(&self, end: bool) -> f64 { - let head = if end { self.end_arrow } else { self.start_arrow }; - head.map_or(0.0, |k| k.reach() * self.arrow_weight(end) + self.width / 2.0) + let head = if end { &self.end_arrow } else { &self.start_arrow }; + // A custom head can paint in front of its tip, which Extend puts past the end point. + let reach = |k: &Arrowhead| if self.arrow_align == ArrowAlign::Extend { k.reach().max(k.length()) } else { k.reach() }; + head.as_ref().map_or(0.0, |k| reach(k) * self.arrow_weight(end) + self.width / 2.0) } /// How far the arrowheads can reach from the path's end points (0 without heads). pub fn arrow_reach(&self) -> f64 { diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 407780177e..3d89615016 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -202,6 +202,13 @@ impl AngleProfile { if v.is_finite() { vectorcraft_geom::normalize_deg(v) } else { 0.0 } } + /// The angle at `t` (0..1) as [`Self::at`] reads it, but in the samples' own running degrees, + /// so the whole turns the curve makes along the stroke are kept; 0 without any samples. + pub fn unwrapped_at(&self, t: f64) -> f64 { + let v = interpolated(&self.points, t).unwrap_or(0.0); + if v.is_finite() { v } else { 0.0 } + } + /// A profile from (position, degrees) samples in order along the stroke: non-finite ones are /// dropped, each sample is joined to the one before it the shorter way round, and the curve is /// simplified to within 1° and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample. diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index 46a0c30c33..18b226c676 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -1,3 +1,7 @@ +use std::sync::Arc; + +use vectorcraft_geom::{PathData, Rect}; + use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -53,8 +57,74 @@ impl Dash { /// (the open arrow's arms are cut square a little behind the box). const HEAD_REACH: f64 = 4.6; +/// An arrowhead the user drew (Stroke panel menu › Edit Arrowheads…), carried with its art so a +/// document that uses it draws it wherever it opens. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(from = "CustomArrowheadData")] +pub struct CustomArrowhead { + /// The name the arrowhead menus show (the symbol's name in the arrowheads file). + pub name: String, + /// The filled outline (non-zero rule) in head units: x from the tip (0) back along the line, y + /// across it, positive to the right of the direction the head points. A head is 4 units wide, + /// as the built-in heads are, and a unit is the head's weight. + pub path: PathData, +} + +#[derive(Deserialize)] +struct CustomArrowheadData { + name: String, + #[serde(default)] + path: PathData, +} + +impl From for CustomArrowhead { + fn from(d: CustomArrowheadData) -> Self { + CustomArrowhead::new(d.name, d.path) + } +} + +impl CustomArrowhead { + /// The most anchors a custom head's outline may have, and the most subpaths. + pub const MAX_ANCHORS: usize = 4096; + /// How far from the tip a custom head's outline may reach, in head units. + pub const MAX_UNITS: f64 = 64.0; + + /// Head `name` with outline `path` (in head units). An outline with more than + /// [`Self::MAX_ANCHORS`] anchors or subpaths, a point that is not finite, or a point farther + /// than [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing. + pub fn new(name: String, path: PathData) -> Self { + let path = if Self::fits(&path) { path } else { PathData::default() }; + CustomArrowhead { name, path } + } + /// Whether outline `path` has at most [`Self::MAX_ANCHORS`] anchors and as many subpaths, with + /// finite points within [`Self::MAX_UNITS`] only. + pub fn fits(path: &PathData) -> bool { + let anchors = path.subpaths.iter().map(|s| s.anchors.len()).sum::(); + let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= Self::MAX_UNITS && p.y.abs() <= Self::MAX_UNITS; + anchors <= Self::MAX_ANCHORS + && path.subpaths.len() <= Self::MAX_ANCHORS + && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)) + } + fn bounds(&self) -> Option { + self.path.control_bounds() + } + /// How far any part of the head can be from its tip, in head units. + pub fn reach(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.x0.hypot(b.y0).max(b.x1.hypot(b.y0)).max(b.x0.hypot(b.y1)).max(b.x1.hypot(b.y1))) + } + /// How far the head spreads either side of its line, in head units. + pub fn half_width(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.y0.abs().max(b.y1.abs())) + } + /// Where the stroke ends under the head, in head units back from the tip: halfway along the + /// head's length. + pub fn stop(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.x1.max(0.0) / 2.0) + } +} + /// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Arrowhead { Triangle, TriangleOpen, @@ -102,6 +172,8 @@ pub enum Arrowhead { /// more) meeting at right angles in a sharp tip, cut square at their ends. Twice as wide as /// the other heads. LineArrow, + /// A head the user drew, with its art. + Custom(Arc), } impl Arrowhead { @@ -151,23 +223,43 @@ impl Arrowhead { ]; /// How far any part of the head can be from its tip, in units of its weight. - pub fn reach(self) -> f64 { + pub fn reach(&self) -> f64 { match self { // Its farthest corners are 4 + ½√2 back and 4 − ½√2 out. Arrowhead::LineArrow => 5.75, + Arrowhead::Custom(c) => c.reach(), _ => HEAD_REACH, } } /// How far the head spreads either side of its line, in units of its weight. - pub fn half_width(self) -> f64 { + pub fn half_width(&self) -> f64 { match self { Arrowhead::LineArrow => 4.0, + Arrowhead::Custom(c) => c.half_width(), _ => 2.0, } } + /// How far past its end point a head placed with [`ArrowAlign::Extend`] can paint, in units of + /// its weight: the built-in heads' length, or a custom head's reach past its tip, which sits + /// [`CustomArrowhead::stop`] past the end point. + pub fn length(&self) -> f64 { + match self { + Arrowhead::Custom(c) => c.stop() + c.reach(), + _ => 4.0, + } + } - /// The name the arrowhead menus show. - pub fn label(self) -> &'static str { + /// The name the arrowhead menus show: [`Self::label`], or a custom head's own name. + pub fn name(&self) -> &str { + match self { + Arrowhead::Custom(c) => &c.name, + _ => self.label(), + } + } + + /// The name the arrowhead menus show for a built-in head ("Custom" for a custom one, whose + /// own name is [`Self::name`]). + pub fn label(&self) -> &'static str { match self { Arrowhead::Triangle => "Triangle", Arrowhead::TriangleOpen => "Triangle (open)", @@ -210,6 +302,7 @@ impl Arrowhead { Arrowhead::TriangleBar => "Triangle to Bar", Arrowhead::Oval => "Oval", Arrowhead::LineArrow => "Line Arrow", + Arrowhead::Custom(_) => "Custom", } } } diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index aa2ba0100c..22e5659051 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -337,13 +337,65 @@ fn arrowhead_names_are_stable_and_every_one_is_listed_once() { ]; for name in SAVED { let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap(); - assert_eq!(serde_json::to_value(a).unwrap(), name); + assert_eq!(serde_json::to_value(&a).unwrap(), name); assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once"); } let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect(); assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct"); } +/// A custom head: a triangle from its tip back 3 units, 2 units right and 1 left of its line. +fn custom_head() -> Arrowhead { + use vectorcraft_geom::{PathData, Point, SubPath}; + let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true)); + Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), path))) +} + +#[test] +fn custom_arrowheads_carry_their_art_and_measure_it() { + let head = custom_head(); + assert_eq!((head.name(), head.label()), ("Wedge", "Custom")); + assert_eq!(head.reach(), 3.0f64.hypot(2.0)); + assert_eq!(head.half_width(), 2.0); + let Arrowhead::Custom(c) = &head else { panic!("custom") }; + assert_eq!(c.stop(), 1.5, "the line stops halfway along the head"); + // Saved with the stroke, art and all, beside the built-in heads' names. + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + (st.start_arrow, st.end_arrow) = (Some(Arrowhead::Triangle), Some(head.clone())); + let json = serde_json::to_value(&st).unwrap(); + assert_eq!(json["start_arrow"], "Triangle"); + assert_eq!(json["end_arrow"]["Custom"]["name"], "Wedge"); + let back: StrokeLayer = serde_json::from_value(json).unwrap(); + assert_eq!(back, st); + // Extended, the tip sits the stop past the end point; on it, only the reach counts. + assert_eq!(head.length(), 1.5 + head.reach()); + assert_eq!(st.head_reach(true), head.length() * 2.0 + 1.0); + st.arrow_align = ArrowAlign::Tip; + assert_eq!(st.head_reach(true), head.reach() * 2.0 + 1.0); +} + +#[test] +fn custom_arrowheads_out_of_bounds_draw_nothing() { + let Arrowhead::Custom(c) = custom_head() else { panic!("custom") }; + let mut path = c.path.clone(); + path.subpaths[0].anchors[1] = vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1e9, 0.0)); + // A file can hold what `new` drops: reading it drops it too. + let json = serde_json::to_value(CustomArrowhead { name: "Wedge".into(), path }).unwrap(); + let far: CustomArrowhead = serde_json::from_value(json).unwrap(); + assert_eq!((far.name.as_str(), far.path.subpaths.len()), ("Wedge", 0)); + assert_eq!((far.reach(), far.half_width(), far.stop()), (0.0, 0.0, 0.0)); + let anchors = vec![vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1.0, 0.0)); CustomArrowhead::MAX_ANCHORS + 1]; + let many = CustomArrowhead::new("Many".into(), vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::new(anchors, true))); + assert!(many.path.subpaths.is_empty(), "too many anchors"); + // Empty subpaths add no anchors, and still count. + let mut hollow = c.path.clone(); + hollow.subpaths.extend(std::iter::repeat_n(vectorcraft_geom::SubPath::new(vec![], false), CustomArrowhead::MAX_ANCHORS)); + assert!(!CustomArrowhead::fits(&hollow), "too many subpaths"); + let json = serde_json::to_value(CustomArrowhead { name: "Hollow".into(), path: hollow }).unwrap(); + let hollow: CustomArrowhead = serde_json::from_value(json).unwrap(); + assert!(hollow.path.subpaths.is_empty(), "too many subpaths, read back"); +} + #[test] fn arrow_align_defaults_to_extend_and_round_trips() { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 2f8b40014e..d9e179286f 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -5,7 +5,8 @@ //! winding and to the start points that keep them from twisting, or to the anchors the Blend //! tool clicked. //! - **Paint**: colours, gradients (stops resampled to a common set when their counts differ), -//! opacity, stroke weight, dashes, miter limit and width profiles interpolate; what can't +//! opacity, stroke weight, dashes, miter limit, width profiles and the pen pressure, tilt, +//! bearing and rotation that brushes read interpolate; what can't //! (caps, joins, brushes, fonts, symbols, text content) switches halfway. //! - **Groups** pair their children in stacking order; the children one group has more of grow //! out of the other group's centre. Compound paths stay compound paths. @@ -22,7 +23,7 @@ use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv}; use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; -use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; +use crate::appearance::{AngleProfile, Appearance, AppearanceItem, Dash, PressureProfile, StrokeLayer, WidthProfile}; use crate::live::{BlendOrientation, BlendSpacing, BlendSpec}; use crate::node::{Node, NodeId, NodeKind}; use crate::text::TextObject; @@ -235,7 +236,8 @@ fn lcm(a: usize, b: usize) -> usize { a.checked_div(x).map_or(0, |q| q * b) } -/// Interpolate width profiles (none is uniform) at every position either has a width point. +/// Interpolate width profiles (none is uniform) at every position either has a width point, +/// keeping their discontinuous points (a position where either steps gives two points). fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> Option { if x.is_none() && y.is_none() { return None; @@ -244,20 +246,135 @@ fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> O let (x, y) = (x.unwrap_or(&uniform), y.unwrap_or(&uniform)); let mut at: Vec = x.points.iter().chain(&y.points).map(|p| p.0).filter(|p| p.is_finite()).collect(); at.sort_by(f64::total_cmp); - at.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - let points = at - .into_iter() - .map(|p| { - let ((l1, r1), (l2, r2)) = (x.at(p), y.at(p)); - (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t)) - }) - .collect(); + // Within the distance `around` reads a discontinuous point at, so a point near one keeps it. + at.dedup_by(|a, b| (*a - *b).abs() <= 1e-7); + // Each profile is a smooth curve through its own points: samples between the positions keep + // both curves' shapes where the other profile's points and steps change the tangents. Two + // profiles with points at the same positions need none (so a preset on both keys stays it). + let same_positions = x.points.len() == y.points.len() && x.points.iter().zip(&y.points).all(|(p, q)| (p.0 - q.0).abs() <= 1e-7); + let per_span = if same_positions { 1 } else { (4096 / at.len().max(1)).clamp(1, 8) }; + let mut points = Vec::with_capacity(at.len() * per_span + 2); + let mix = |p: f64, (l1, r1): (f64, f64), (l2, r2): (f64, f64)| (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t)); + for (k, &p) in at.iter().enumerate() { + let ((xb, xa), (yb, ya)) = (x.around(p), y.around(p)); + points.push(mix(p, xb, yb)); + // A discontinuous point ends the curve on each side even where both widths match. + if steps_at(x, p) || steps_at(y, p) { + points.push(mix(p, xa, ya)); + } + if let Some(&q) = at.get(k + 1) { + for j in 1..per_span { + let f = p + (q - p) * j as f64 / per_span as f64; + points.push(mix(f, x.at(f), y.at(f))); + } + } + } Some(WidthProfile { points }) } -/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale and width -/// profile into `o` (a copy of the nearer stroke, so caps, joins, alignment, arrowheads and -/// brushes switch halfway). +/// Whether width profile `w` has a discontinuous point at `p` (two points there, within `1e-7`, +/// as [`WidthProfile::around`] reads them). +fn steps_at(w: &WidthProfile, p: f64) -> bool { + let i = w.points.partition_point(|q| q.0 < p - 1e-7); + w.points.get(i..).unwrap_or_default().iter().take_while(|q| q.0 <= p + 1e-7).count() > 1 +} + +/// The positions along the stroke where either of two pen recordings has a sample, in order. +fn sample_positions(x: Option<&[(f64, f64)]>, y: Option<&[(f64, f64)]>) -> Vec { + let mut at: Vec = x.unwrap_or_default().iter().chain(y.unwrap_or_default()).map(|p| p.0).filter(|p| p.is_finite()).collect(); + at.sort_by(f64::total_cmp); + // Within the distance `step_after` reads a step at, so a sample near a step keeps the step. + at.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); + if at.is_empty() { + at = vec![0.0, 1.0]; + } + at +} + +/// The value pen recording `p` steps to at position `f`, where two or more of its samples share +/// that position (reading at `f` gives the first of them); None where it doesn't step. +fn step_after(p: Option<&[(f64, f64)]>, f: f64) -> Option { + let p = p?; + let i = p.partition_point(|q| q.0 < f - 1e-9); + let j = p.partition_point(|q| q.0 <= f + 1e-9).checked_sub(1)?; + (j > i && (p.get(i)?.0 - f).abs() <= 1e-9).then(|| p.get(j).map(|q| q.1)).flatten() +} + +/// Two pen recordings read at every position either has a sample, as (position, x's value, y's +/// value): `read_x` and `read_y` read them, and `clean` makes a stepped sample's value one they +/// would read. A position where either steps gives two, before and after the step. +fn paired_samples( + x: Option<&[(f64, f64)]>, + y: Option<&[(f64, f64)]>, + read_x: impl Fn(f64) -> f64, + read_y: impl Fn(f64) -> f64, + clean: impl Fn(f64) -> f64, +) -> Vec<(f64, f64, f64)> { + let mut out = Vec::new(); + for f in sample_positions(x, y) { + let (a, b) = (read_x(f), read_y(f)); + out.push((f, a, b)); + let (sa, sb) = (step_after(x, f), step_after(y, f)); + if sa.is_some() || sb.is_some() { + out.push((f, sa.map_or(a, &clean), sb.map_or(b, &clean))); + } + } + out +} + +/// Interpolate pen pressure or tilt recordings at every position either has a sample, keeping +/// their steps. A stroke without one reads as [`PressureProfile::MID`], as brushes read it; None +/// when neither has one. +fn lerp_pressure(x: Option<&PressureProfile>, y: Option<&PressureProfile>, t: f64) -> Option { + if x.is_none() && y.is_none() { + return None; + } + let read = |p: Option<&PressureProfile>, f: f64| p.map_or(PressureProfile::MID, |p| p.at(f)); + let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { PressureProfile::MID }; + let samples = paired_samples(x.map(|p| p.points.as_slice()), y.map(|p| p.points.as_slice()), |f| read(x, f), |f| read(y, f), clean); + PressureProfile::from_samples(samples.into_iter().map(|(f, a, b)| (f, lerp_f64(a, b, t)))) +} + +/// Interpolate pen angle recordings (bearing or barrel rotation) at every position either has a +/// sample: the steps turn from one key's angle to the other's the shorter way round at the +/// stroke's start, and keep the whole turns each recording makes along the stroke. A stroke +/// without one reads as 0°, as brushes read it; None when neither has one. +fn lerp_angle(x: Option<&AngleProfile>, y: Option<&AngleProfile>, t: f64) -> Option { + if x.is_none() && y.is_none() { + return None; + } + let read = |p: Option<&AngleProfile>, f: f64| p.map_or(0.0, |p| p.unwrapped_at(f)); + let clean = |v: f64| if v.is_finite() { v } else { 0.0 }; + let pairs = paired_samples(x.map(|p| p.points.as_slice()), y.map(|p| p.points.as_slice()), |f| read(x, f), |f| read(y, f), clean); + // Whole turns that bring the two angles within half a turn of each other at the first position. + let d0 = pairs.first().map_or(0.0, |&(_, a, b)| b - a); + let turns = vectorcraft_geom::normalize_deg(d0) - d0; + let value = |a: f64, b: f64| a + (b - a + turns) * t; + // Both curves are straight between two positions; samples are added where the steps turn + // through 90° or more between them, since `AngleProfile::from_samples` joins neighbours the + // shorter way round. A document's recordings can be long, so the added samples are limited. + let mut budget = 4 * PressureProfile::MAX_POINTS; + let mut samples = Vec::with_capacity(pairs.len()); + let mut prev: Option<(f64, f64)> = None; + for (f, a, b) in pairs { + let v = value(a, b); + if let Some((pf, pv)) = prev.filter(|p| f - p.0 > 1e-9) { + let parts = ((v - pv).abs() / 90.0).ceil().min((budget + 1) as f64) as usize; + for k in 1..parts { + let g = pf + (f - pf) * k as f64 / parts as f64; + samples.push((g, value(read(x, g), read(y, g)))); + } + budget -= parts.saturating_sub(1).min(budget); + } + samples.push((f, v)); + prev = Some((f, v)); + } + AngleProfile::from_samples(samples) +} + +/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale, width +/// profile and the pen pressure, tilt, bearing and rotation its brush reads into `o` (a copy of +/// the nearer stroke, so caps, joins, alignment, arrowheads and brushes switch halfway). fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) { let tf = t as f32; o.paint = lerp_paint(&x.paint, &y.paint, tf); @@ -267,6 +384,10 @@ fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) { o.dash = lerp_dash(x.dash.as_ref(), y.dash.as_ref(), t); o.arrow_scale = (lerp_f64(x.arrow_scale.0, y.arrow_scale.0, t), lerp_f64(x.arrow_scale.1, y.arrow_scale.1, t)); o.profile = lerp_profile(x.profile.as_ref(), y.profile.as_ref(), t); + o.pressure = lerp_pressure(x.pressure.as_ref(), y.pressure.as_ref(), t); + o.tilt = lerp_pressure(x.tilt.as_ref(), y.tilt.as_ref(), t); + o.bearing = lerp_angle(x.bearing.as_ref(), y.bearing.as_ref(), t); + o.rotation = lerp_angle(x.rotation.as_ref(), y.rotation.as_ref(), t); } /// Interpolate appearance stacks: item by item when their structure matches, else the top fill @@ -390,6 +511,161 @@ fn reverse_closed(sp: &mut SubPath) { sp.anchors.rotate_right(1); } +/// How a key's subpaths were turned for its blend, so that its stroke's profiles, pen +/// recordings and arrowheads turn with it: position `u` along the turned subpath is position +/// `start + u` (`start - u` when `reversed`) along the key's own, wrapping round a closed one. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Orient { + start: f64, + reversed: bool, + closed: bool, +} + +impl Orient { + fn of(sp: &SubPath) -> Self { + Self { closed: sp.closed, ..Self::default() } + } + + /// The subpath now starts at the fraction `f` of its length. + fn rotate(&mut self, f: f64) { + self.start += if self.reversed { -f } else { f }; + } + + /// The subpath now runs the other way. + fn reverse(&mut self) { + self.start += if self.reversed { -1.0 } else { 1.0 }; + self.reversed = !self.reversed; + } + + /// As [`start_at`] turns `sp` (call it before). + fn start_at(&mut self, sp: &SubPath, i: usize) { + let n = sp.anchors.len(); + if i >= n { + return; + } + if sp.closed { + self.rotate(arc_fraction(sp, i)); + } else if i + 1 == n && n > 1 { + self.reverse(); + } + } + + /// Where a closed subpath now starts, as a fraction of its length; 0 for an open one. + fn seam(&self) -> f64 { + let s = if self.closed { self.start.rem_euclid(1.0) } else { 0.0 }; + if s > 1e-9 && s < 1.0 - 1e-9 { s } else { 0.0 } + } + + fn is_identity(&self) -> bool { + !self.reversed && self.seam() == 0.0 + } + + /// Whether two subpaths were turned alike, so one stroke along both turns the same way. + fn same_turn(&self, o: &Self) -> bool { + self.reversed == o.reversed && (self.seam() - o.seam()).abs() < 1e-9 + } +} + +/// How far along `sp` its anchor `i` sits, as a fraction of its length. +fn arc_fraction(sp: &SubPath, i: usize) -> f64 { + let pos = sp.anchor_arc_positions(); + match (pos.get(i), pos.last()) { + (Some(&at), Some(&total)) if total.is_finite() && total > 1e-12 => (at / total).clamp(0.0, 1.0), + _ => 0.0, + } +} + +/// Samples (position, value) along a key's subpath, read along the subpath turned as `o` says. +/// A closed subpath starting elsewhere starts the samples there too: the ones before it move +/// past the seam (`wrap` applied), which keeps any step the seam makes, and a value between +/// samples is read with `at`. A reversed subpath then reads them backwards (`flip` applied). +fn reoriented(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wrap: impl Fn(V) -> V, flip: impl Fn(V) -> V) -> Vec<(f64, V)> { + let s = o.seam(); + let mut out = points.to_vec(); + if s > 0.0 + && let (Some(&(_, first)), Some(&(_, last))) = (points.first(), points.last()) + { + let i = points.partition_point(|q| q.0 < s - 1e-9); + let j = points.partition_point(|q| q.0 <= s + 1e-9).max(i); + let (before, after) = match points.get(i..j) { + Some([p, .., q]) => (p.1, q.1), + Some([p]) => (p.1, p.1), + _ => (at(s), at(s)), + }; + out = Vec::with_capacity(points.len() + 4); + out.push((0.0, after)); + out.extend(points.get(j..).unwrap_or_default().iter().map(|&(t, v)| (t - s, v))); + out.push((1.0 - s, last)); + out.push((1.0 - s, wrap(first))); + out.extend(points.get(..i).unwrap_or_default().iter().map(|&(t, v)| (t + 1.0 - s, wrap(v)))); + out.push((1.0, wrap(before))); + } + if o.reversed { + out = out.into_iter().rev().map(|(t, v)| (1.0 - t, flip(v))).collect(); + } + out +} + +/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap +/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its +/// arrowheads. +fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { + if o.is_identity() { + return; + } + let same = |v: f64| v; + if let Some(p) = &mut s.profile { + let mut pts: Vec<(f64, (f64, f64))> = p.points.iter().map(|&(t, l, r)| (t, (l, r))).collect(); + // A width profile is a smooth curve through its points: where the new start splits a span, + // samples of that span keep its curve once its ends become ends of the profile. + let seam = o.seam(); + let i = p.points.partition_point(|q| q.0 < seam); + if seam > 0.0 + && let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.points.get(j)), p.points.get(i)) + && seam - a.0 > 1e-9 + && b.0 - seam > 1e-9 + { + const SAMPLES: usize = 32; + let (a, b) = (a.0, b.0); + let extra: Vec<(f64, (f64, f64))> = + (1..SAMPLES).map(|k| a + (b - a) * k as f64 / SAMPLES as f64).filter(|f| (f - seam).abs() > 1e-9).map(|f| (f, p.at(f))).collect(); + pts.splice(i..i, extra); + } + let out = reoriented(&pts, o, |f| p.at(f), |v| v, |(l, r)| (r, l)); + p.points = out.into_iter().map(|(t, (l, r))| (t, l, r)).collect(); + } + for p in [&mut s.pressure, &mut s.tilt].into_iter().flatten() { + let out = reoriented(&p.points, o, |f| p.at(f), same, same); + p.points = out; + } + for p in [&mut s.bearing, &mut s.rotation].into_iter().flatten() { + // The samples moved past the seam carry the whole turns the recording makes along the + // stroke, so the angle does not jump by them there. + let turns = match (p.points.first(), p.points.last()) { + (Some(a), Some(b)) if (b.1 - a.1).is_finite() => ((b.1 - a.1) / 360.0).round() * 360.0, + _ => 0.0, + }; + let out = reoriented(&p.points, o, |f| p.unwrapped_at(f), |v| v + turns, same); + p.points = out; + } + if o.reversed && !o.closed { + std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); + s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); + } +} + +/// `n` with every stroke read along its subpaths turned as `o` says. +fn reorient_strokes(n: &mut Node, o: Orient) { + if o.is_identity() { + return; + } + for it in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = it { + reorient_stroke(s, o); + } + } +} + /// Anchor positions of a subpath relative to its box, scaled to unit size. fn normalised(sp: &SubPath) -> Vec { let pts = sp.anchors.iter().map(|a| a.p); @@ -427,6 +703,8 @@ fn best_rotation(a: &SubPath, b: &SubPath) -> usize { pub struct PathPair { a: Vec, b: Vec, + /// How each path's subpaths were turned, where all of them turned alike (else unturned). + orient: (Orient, Orient), } impl PathPair { @@ -438,6 +716,7 @@ impl PathPair { let cb = b.bounds().map(|r| r.center()).unwrap_or_default(); let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed); let (mut va, mut vb) = (Vec::with_capacity(n), Vec::with_capacity(n)); + let mut orient = (Orient::default(), Orient::default()); for i in 0..n { let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) { (Some(x), Some(y)) => (x.clone(), y.clone()), @@ -445,17 +724,21 @@ impl PathPair { (None, Some(y)) => (degenerate(y, ca), y.clone()), (None, None) => continue, }; + let (mut oa, mut ob) = (Orient::of(&sa), Orient::of(&sb)); let explicit = i == 0 && (starts.0.is_some() || starts.1.is_some()); if explicit { if let Some(s) = starts.0 { + oa.start_at(&sa, s); start_at(&mut sa, s); } if let Some(s) = starts.1 { + ob.start_at(&sb, s); start_at(&mut sb, s); } } let closed = sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2; if closed && (sa.area() * sb.area()) < 0.0 { + ob.reverse(); reverse_closed(&mut sb); } let m = sa.anchors.len().max(sb.anchors.len()); @@ -463,12 +746,26 @@ impl PathPair { grow(&mut sb, m); if closed && !explicit { let r = best_rotation(&sa, &sb); + if r != 0 { + ob.rotate(arc_fraction(&sb, r)); + } sb.anchors.rotate_left(r); } + // One stroke runs along every subpath of a key, each reading it by its own length, so + // it turns only when all of the key's subpaths turned alike. + if i == 0 { + orient = (oa, ob); + } + if i < a.subpaths.len() && !orient.0.same_turn(&oa) { + orient.0 = Orient::default(); + } + if i < b.subpaths.len() && !orient.1.same_turn(&ob) { + orient.1 = Orient::default(); + } va.push(sa); vb.push(sb); } - Self { a: va, b: vb } + Self { a: va, b: vb, orient } } /// The path at `t` (0 = `a`, 1 = `b`). @@ -583,7 +880,12 @@ impl Lerp { _ => Shape::Other, }, }; - Self { a: a.clone(), b: b.clone(), shape } + let (mut a, mut b) = (a.clone(), b.clone()); + if let Shape::Path { pair, .. } = &shape { + reorient_strokes(&mut a, pair.orient.0); + reorient_strokes(&mut b, pair.orient.1); + } + Self { a, b, shape } } /// The object at `t` (0 = `a`, 1 = `b`), with id 0. @@ -1485,7 +1787,7 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { // Even position of the step, then where the easing puts its shape and its colours. let u = j as f64 / (n + 1) as f64; // Easing needs a spine: a blend made without one keeps its steps evenly spaced. - let eased = rail.is_some() && !spec.no_spine; + let eased = rail.is_some() && spec.eases_along_spine(); let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u)); let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { @@ -1500,6 +1802,7 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::appearance::Arrowhead; use vectorcraft_geom::{Rect, shapes}; #[test] @@ -1755,6 +2058,146 @@ mod tests { assert_eq!(p.at(0.5), (1.0, 1.0)); } + #[test] + fn pen_pressure_tilt_and_angles_interpolate_for_brushes() { + let stroked = |pressure: Option, tilt: Option, bearing: Option| { + let mut ap = Appearance::default(); + ap.set_stroke(Paint::solid(Color::BLACK)); + if let Some(s) = ap.stroke_mut() { + s.brush = Some("Pressure Round".into()); + s.pressure = pressure.map(|p| PressureProfile { points: vec![(0.0, p), (1.0, p)] }); + s.tilt = tilt.map(|p| PressureProfile { points: vec![(0.0, p), (1.0, p)] }); + s.bearing = bearing.map(|a| AngleProfile { points: vec![(0.0, a), (1.0, a)] }); + } + ap + }; + let at = |ap: &Appearance, f: f64| { + let s = ap.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + let read = |p: Option<&PressureProfile>| p.map_or(PressureProfile::MID, |p| p.at(f)); + (read(s.pressure.as_ref()), read(s.tilt.as_ref()), s.bearing.as_ref().map_or(0.0, |p| p.at(f))) + }; + let (light, heavy) = (stroked(Some(0.0), Some(0.2), Some(170.0)), stroked(Some(1.0), Some(0.6), Some(-170.0))); + let (p, tilt, bearing) = at(&lerp_appearance(&light, &heavy, 0.25), 0.5); + assert!((p - 0.25).abs() < 1e-9 && (tilt - 0.3).abs() < 1e-9, "a quarter of the way: {p} {tilt}"); + assert!((bearing - 175.0).abs() < 1e-9, "the angle turns the shorter way round, through 180°: {bearing}"); + assert!((at(&lerp_appearance(&light, &heavy, 0.5), 0.5).2.abs() - 180.0).abs() < 1e-9); + // A stroke drawn without a recording reads as half pressure, half tilt and 0°. + let plain = stroked(None, None, None); + let (p, tilt, bearing) = at(&lerp_appearance(&heavy, &plain, 0.5), 0.0); + assert!((p - 0.75).abs() < 1e-9 && (tilt - 0.55).abs() < 1e-9 && (bearing + 85.0).abs() < 1e-9, "{p} {tilt} {bearing}"); + // Profiles that vary along the stroke interpolate at each position. + let mut ramp = stroked(None, None, None); + if let Some(s) = ramp.stroke_mut() { + s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }); + } + let mid = lerp_appearance(&ramp, &heavy, 0.5); + assert!((at(&mid, 0.0).0 - 0.5).abs() < 1e-9 && (at(&mid, 0.5).0 - 0.75).abs() < 1e-9, "{:?}", mid.stroke().map(|s| &s.pressure)); + // A step in a recording (two samples at one position) stays a step. + let mut stepped = stroked(None, None, None); + if let Some(s) = stepped.stroke_mut() { + s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] }); + } + let mid = lerp_appearance(&stepped, &stepped, 0.5); + assert!(at(&mid, 0.25).0.abs() < 1e-9 && (at(&mid, 0.75).0 - 1.0).abs() < 1e-9, "{:?}", mid.stroke().map(|s| &s.pressure)); + // A whole turn recorded along a stroke stays a whole turn in the steps. + let mut turning = stroked(None, None, None); + if let Some(s) = turning.stroke_mut() { + s.rotation = Some(AngleProfile { points: vec![(0.0, 0.0), (1.0, 360.0)] }); + } + let rot = |ap: &Appearance, f: f64| ap.stroke().and_then(|s| s.rotation.as_ref()).map_or(0.0, |p| p.at(f)); + let same = lerp_appearance(&turning, &turning, 0.5); + assert!((rot(&same, 0.25) - 90.0).abs() < 1e-9 && (rot(&same, 0.5).abs() - 180.0).abs() < 1e-9, "{:?}", same.stroke().map(|s| &s.rotation)); + let half = lerp_appearance(&turning, &plain, 0.5); + assert!((rot(&half, 0.5) - 90.0).abs() < 1e-9 && (rot(&half, 1.0).abs() - 180.0).abs() < 1e-9, "{:?}", half.stroke().map(|s| &s.rotation)); + // Neither key has a recording: the steps have none either. + let none = lerp_appearance(&plain, &plain, 0.5); + let s = none.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + assert!(s.pressure.is_none() && s.tilt.is_none() && s.bearing.is_none() && s.rotation.is_none()); + } + + #[test] + fn a_point_near_the_other_keys_step_keeps_the_step() { + let flat = PressureProfile { points: vec![(0.0, 0.0), (0.4999995, 0.0), (1.0, 0.0)] }; + let stepped = PressureProfile { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] }; + let p = lerp_pressure(Some(&flat), Some(&stepped), 1.0).unwrap_or_default(); + assert!((p.at(0.75) - 1.0).abs() < 1e-9, "{p:?}"); + let narrow = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.4999995, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let stepped = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 0.0, 0.0), (1.0, 0.0, 0.0)] }; + let w = lerp_profile(Some(&narrow), Some(&stepped), 1.0).unwrap_or_default(); + assert!(w.at(0.75).0.abs() < 1e-9, "{w:?}"); + // The other key's step doesn't reshape a smooth profile's curve. + let early = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.25, 1.0, 1.0), (0.25, 0.5, 0.5), (1.0, 0.5, 0.5)] }; + let w = lerp_profile(Some(&WidthProfile::lens()), Some(&early), 0.0).unwrap_or_default(); + for u in [0.1, 0.125, 0.2, 0.3, 0.4] { + let (got, want) = (w.at(u).0, WidthProfile::lens().at(u).0); + assert!((got - want).abs() < 5e-3, "at {u}: {got} for {want}: {w:?}"); + } + let lens = WidthProfile::lens(); + assert_eq!(lerp_profile(Some(&lens), Some(&lens), 0.5).and_then(|p| p.preset_id()), Some("lens")); + } + + #[test] + fn stroke_profiles_turn_with_a_key_path_started_elsewhere() { + let keyed = |path: PathData, edit: &dyn Fn(&mut StrokeLayer)| { + let mut ap = Appearance::default(); + ap.set_stroke(Paint::solid(Color::BLACK)); + if let Some(s) = ap.stroke_mut() { + edit(s); + } + Node::path(NodeId(1), path, ap) + }; + let stroke = |n: &Node| n.appearance.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + let pressure = |s: &StrokeLayer, f: f64| s.pressure.as_ref().map_or(PressureProfile::MID, |p| p.at(f)); + // An open key clicked at its last anchor runs the other way, and so do its pressure, its + // width profile (whose sides swap) and its arrowheads. + let line = |x: f64| PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0)], false)); + let light = keyed(line(0.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 0.0)] })); + let ramp = keyed(line(100.0), &|s| { + s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] }); + s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 0.0, 0.0)] }); + s.start_arrow = Some(Arrowhead::Triangle); + s.arrow_scale = (200.0, 100.0); + }); + let blend = Lerp::new(&light, &ramp, (None, Some(1))); + let s = stroke(&blend.at(0.25)); + assert!(pressure(&s, 0.0).abs() < 1e-9 && (pressure(&s, 1.0) - 0.25).abs() < 1e-9, "{:?}", s.pressure); + let s = stroke(&blend.at(1.0)); + assert_eq!(s.profile.as_ref().map(|p| p.at(1.0)), Some((0.0, 1.0)), "{:?}", s.profile); + assert_eq!((s.start_arrow, s.end_arrow, s.arrow_scale), (None, Some(Arrowhead::Triangle), (100.0, 200.0))); + // A key whose other subpaths keep their direction keeps its one stroke as it is. + let two = |x: f64| { + let sub = |y: f64| SubPath::polyline(&[Point::new(x, y), Point::new(x + 10.0, y)], false); + PathData::new(vec![sub(0.0), sub(20.0)]) + }; + let ramps = keyed(two(100.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] })); + let s = stroke(&Lerp::new(&keyed(two(0.0), &|_| {}), &ramps, (None, Some(1))).at(1.0)); + assert!((pressure(&s, 0.0) - 1.0).abs() < 1e-9, "{:?}", s.pressure); + // A closed key started half way round starts its recordings there too, keeping the step + // where its own start was and the whole turn its rotation makes. + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)); + let plain = keyed(square(0.0), &|_| {}); + let turning = keyed(square(100.0), &|s| { + s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }); + s.rotation = Some(AngleProfile { points: vec![(0.0, 0.0), (1.0, 360.0)] }); + s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }); + }); + let blend = Lerp::new(&plain, &turning, (Some(0), Some(2))); + let s = stroke(&blend.at(1.0)); + assert!((pressure(&s, 0.25) - 0.75).abs() < 1e-9 && (pressure(&s, 0.75) - 0.25).abs() < 1e-9, "{:?}", s.pressure); + let width = s.profile.as_ref().map_or((1.0, 1.0), |p| p.at(0.75)); + assert!((width.0 - 0.75).abs() < 1e-6 && (width.1 - 0.75).abs() < 1e-6, "the width steps where the key started: {:?}", s.profile); + // A width curve split by the new start keeps its shape. + let lensed = keyed(square(100.0), &|s| s.profile = Some(WidthProfile::lens())); + let s = stroke(&Lerp::new(&plain, &lensed, (Some(0), Some(1))).at(1.0)); + for u in [0.05, 0.125, 0.2, 0.6, 0.7, 0.74, 0.76, 0.8, 0.875, 0.95] { + let (got, want) = (s.profile.as_ref().map_or(1.0, |p| p.at(u).0), WidthProfile::lens().at((u + 0.25) % 1.0).0); + assert!((got - want).abs() < 2e-3, "at {u}: {got} for {want}: {:?}", s.profile); + } + let rot = |t: f64, f: f64| stroke(&blend.at(t)).rotation.as_ref().map_or(0.0, |p| p.unwrapped_at(f)); + assert!((vectorcraft_geom::normalize_deg(rot(1.0, 0.25)) + 90.0).abs() < 1e-6, "{:?}", s.rotation); + assert!((rot(0.5, 0.499) - rot(0.5, 0.501)).abs() < 1.0, "no jump where the key started: {:?}", stroke(&blend.at(0.5)).rotation); + } + #[test] fn odd_dash_blends_to_solid_with_closed_gaps() { let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2c5759f202..2ed800ceec 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -82,8 +82,8 @@ pub(crate) mod skip; pub use appearance::StrokeGradientMode; pub use appearance::{ - AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, - SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, + AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, CustomArrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, + ProfilePreset, SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, }; pub use assets::ExportAsset; pub use compound_shape::ShapeMode; diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs index 56405e0d18..047e1ad7b5 100644 --- a/crates/doc/src/live/blend_spec.rs +++ b/crates/doc/src/live/blend_spec.rs @@ -119,6 +119,12 @@ impl BlendSpec { pub fn start(&self, i: usize) -> Option { self.starts.get(i).copied().flatten().map(|a| a as usize) } + + /// Whether [`Self::ease`] moves the steps: the blend has a spine ([`Self::no_spine`] is off) + /// and a replaced spine or Align to Path, the cases where the steps are placed along it. + pub fn eases_along_spine(&self) -> bool { + !self.no_spine && (self.spine.is_some() || self.orientation == BlendOrientation::AlignToPath) + } } /// The Blend panel's Create Spine setting: whether a new blend gets a spine. diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 8279507e01..50a4033e4c 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -400,13 +400,15 @@ impl StrokeLayer { /// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the /// chord the path bends through under it, so anywhere within its length of the end. fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool { - let Some(kind) = (if end { self.end_arrow } else { self.start_arrow }) else { return false }; + let Some(kind) = (if end { &self.end_arrow } else { &self.start_arrow }) else { return false }; let (hw, v) = (self.arrow_weight(end), p - at); match self.arrow_align { ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol, ArrowAlign::Extend => { let along = v.dot(out); - v.cross(out).abs() <= kind.half_width() * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -kind.reach() * hw - tol + v.cross(out).abs() <= kind.half_width() * hw + tol + && along <= kind.length() * hw + self.width / 2.0 + tol + && along >= -kind.reach() * hw - tol } } } diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index bb3725072e..a2c2a66498 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -237,6 +237,10 @@ pub struct ParaStyle { /// Kinsoku Shori Type (Paragraph panel menu): how a line keeps those characters off its start /// and end, by pushing them in onto the line or out to the next one. No effect without kinsoku. pub kinsoku_type: KinsokuShoriType, + /// Bunri-Kinshi (Paragraph panel menu): a line never breaks inside an inseparable pair (two em + /// dashes, two ellipses, two two-dot leaders, a vertical kana repeat mark's two halves). Kept + /// with kinsoku None, where it has no effect. Off by default. + pub bunri_kinshi: bool, /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. @@ -339,6 +343,8 @@ struct ParaStyleFile { #[serde(default, skip_serializing_if = "crate::skip::is_default")] kinsoku_type: KinsokuShoriType, #[serde(default, skip_serializing_if = "crate::skip::is_default")] + bunri_kinshi: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, @@ -375,6 +381,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -401,6 +408,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -431,6 +439,7 @@ impl From for ParaStyle { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -456,6 +465,7 @@ impl From for ParaStyle { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 795fc69bdf..24f3d68af6 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints}; use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, - has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, }; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { @@ -41,8 +41,8 @@ fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { fn clear_item_effects(item: &mut AppearanceItem) { match item { - AppearanceItem::Fill(f) => f.effects.retain(|e| !is_geometry(&e.id)), - AppearanceItem::Stroke(s) => s.effects.retain(|e| !is_geometry(&e.id)), + AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects), + AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects), } } @@ -337,7 +337,7 @@ fn moved_type_group(n: &Node) -> Option { g.knockout = Knockout::Off; g.knockout_shape = n.knockout_shape; g.extras_mut().mask = n.extras().mask.clone(); - g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + g.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(g) } @@ -397,7 +397,8 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { return None; } reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d)); - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); m.appearance.items.clear(); m.appearance.contents_index = None; Some(m) @@ -439,7 +440,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - m.appearance.effects.retain(|e| !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); Some((g, rule, ctx, m)) } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 84991392b2..031d3e1d98 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -94,6 +94,8 @@ fn lengths_of(id: &str) -> Lengths { "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), "brushStrokes.accentedEdges" => always(&["edgeWidth"]), "artistic.paletteKnife" => always(&["strokeSize"]), + "artistic.coloredPencil" => always(&["pencilWidth"]), + "artistic.paintDaubs" => always(&["brushSize"]), "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), "brushStrokes.spatter" => always(&["sprayRadius"]), "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), @@ -258,14 +260,14 @@ pub fn effect_catalog() -> Vec { "distort.roughen", "Roughen…", DT, - "{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", + "{size: 0..100 (5; 0 keeps the outline and adds its points along it), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}), ), g( "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( @@ -387,7 +389,7 @@ pub fn effect_catalog() -> Vec { "artistic.paintDaubs", "Paint Daubs…", ARTISTIC, - "{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + "{brushSize: 1..50 (10; spacing of the dab centers in document units),sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}), ), r( diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index c070457027..a5bc941b16 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -3,6 +3,7 @@ use serde_json::Value; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2}; +use vectorcraft_pathops::addons::pathtools::insert_at; use crate::util::{catmull_rom, fit_piece, flag, map_nonlinear_within, mean_size, noise, normal_at, num, pieces, seed, seg_cubic, smooth_points}; @@ -79,7 +80,8 @@ const MAX_ROUGHEN_POINTS: usize = 2000; /// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. Where /// that would make more than `max` anchors, the points are spaced further apart until it doesn't -/// (stacked Roughens multiply their points, #3578). +/// (stacked Roughens multiply their points, #3578). At size 0 no point moves: each segment is +/// split into its pieces, evenly along its length, and the outline stays as it is. pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let size = num(p, "size", 5.0).clamp(0.0, 1e4); let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size }; @@ -89,6 +91,23 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let lens: Vec = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i).arclen(0.1))).collect(); let spacing = fit_piece(&lens, spacing, MAX_ROUGHEN_POINTS, max.saturating_sub(path.anchor_count())); let mut lens = lens.iter(); + if amp == 0.0 { + // Each segment's pieces, cut at equal lengths as `pathtools::add_points` cuts them. + let mut subs = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let mut places = vec![]; + for (i, &coarse) in (0..sp.segment_count()).zip(&mut lens) { + let k = pieces(coarse, spacing, MAX_ROUGHEN_POINTS); + let c = sp.segment(i); + let len = c.arclen(ROUGHEN_ARCLEN_ACCURACY); + if len > 1e-9 && len.is_finite() { + places.extend((1..k).map(|j| (i, c.inv_arclen(len * j as f64 / k as f64, ROUGHEN_ARCLEN_ACCURACY)))); + } + } + subs.push(insert_at(sp, &places).0); + } + return PathData::new(subs); + } let smooth = smooth_points(p); let seed = seed(p); let mut subs = vec![]; @@ -118,6 +137,8 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { PathData::new(subs) } +const ROUGHEN_ARCLEN_ACCURACY: f64 = 1e-4; + /// The move and the number of copies of a Transform effect with params `p` that only moves what /// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects, /// or moves by a random share, or moves pattern paints along (Transform Patterns), or scales diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 57f2fc8613..015490a74d 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -265,7 +265,7 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node { fn without_geometry_effects(n: &mut Node) { if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) { let mut ap = (*n.appearance).clone(); - ap.effects.retain(|e| !crate::is_geometry(&e.id)); + ap.effects = crate::raster::without_geometry(&ap.effects); n.appearance = ap.into(); } } @@ -794,7 +794,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { carry.apply(&mut m.appearance); } } - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); if paint { let shapes = member_shapes(doc, &m); let bounds = m.geometric_bounds(); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 8591bbab2c..80cfef7b96 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; +pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects, without_geometry}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index cfa51ca2b0..07140e2bc9 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -1,11 +1,13 @@ //! Raster (painted) effects: descriptions for the renderer. -use serde_json::Value; +use std::borrow::Cow; + +use serde_json::{Map, Value, json}; use vectorcraft_doc::color::{BlendMode, Color}; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::merged_params; use crate::pixel::{self, PixelFx, PixelSpace}; @@ -384,9 +386,10 @@ fn opacity(p: &Value) -> f32 { (num(p, "opacity", 75.0) / 100.0).clamp(0.0, 1.0) as f32 } -/// The visible raster effects of `effects`, in stack order. +/// The visible raster effects of `effects`, in stack order, each following the Transform effects +/// listed after it ([`followed`]). pub fn raster_effects(effects: &[Effect]) -> Vec { - effects + followed(effects) .iter() .filter(|e| e.visible) .filter_map(|e| { @@ -426,6 +429,116 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { .collect() } +/// `effects` with each raster effect following the visible Transform effects listed after it +/// ([`follow_transform`]): effects apply in the order they are listed, so a Transform effect +/// transforms what the raster effects before it paint, whatever its Scale Strokes & Effects says. +/// A hidden raster effect follows too, so it draws the same when it is shown after the Transform +/// effect has been expanded. A raster effect that a Transform effect making copies or moving at +/// random follows stays as it is, as does one after the last Transform effect. Borrowed when +/// nothing changes. +pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { + if !effects.iter().any(|e| e.visible && e.id == "distort.transform") { + return Cow::Borrowed(effects); + } + let mut out: Option> = None; + // The linear part of the Transform effects after the effect at hand, the last one outermost; + // `None` once one of them makes copies or moves at random. + let mut after = Some(Affine::IDENTITY); + for (i, e) in effects.iter().enumerate().rev() { + if e.id == "distort.transform" { + after = after.and_then(|a| transform_step(e).map(|s| a * s)); + continue; + } + let Some(a) = after.filter(|a| *a != Affine::IDENTITY && follows(&e.id)) else { continue }; + let list = out.get_or_insert_with(|| effects.to_vec()); + if let Some(e) = list.get_mut(i) { + follow_transform(e, a); + } + } + out.map_or(Cow::Borrowed(effects), Cow::Owned) +} + +/// `effects` without their geometry effects, each raster effect following the Transform effects +/// it loses ([`followed`]): what an object, or a fill or stroke, keeps once its geometry effects +/// are applied to its art. +pub fn without_geometry(effects: &[Effect]) -> Vec { + followed(effects).iter().filter(|e| !crate::is_geometry(&e.id)).cloned().collect() +} + +/// Make the raster effects of `effects`, all listed before Transform effect `t`, follow `t` as +/// [`followed`] makes them follow it, for art that applies its effects one at a time. `false` +/// when `t` leaves them as they are. +pub(crate) fn follow_one(effects: &mut [Effect], t: &Effect) -> bool { + let Some(a) = transform_step(t).filter(|a| *a != Affine::IDENTITY) else { return false }; + let mut changed = false; + for e in effects.iter_mut().filter(|e| follows(&e.id)) { + follow_transform(e, a); + changed = true; + } + changed +} + +/// How many of the effects of `effects` that [`without_geometry`] keeps no longer follow the +/// Transform effects listed after `effects` ([`followed`]): those before the last visible Transform +/// effect among them that makes copies or moves at random. +pub(crate) fn held_back(effects: &[Effect]) -> usize { + let Some(last) = effects.iter().rposition(|e| transform_step(e).is_none()) else { return 0 }; + effects.get(..last).unwrap_or_default().iter().filter(|e| !crate::is_geometry(&e.id)).count() +} + +/// Does `e` stop the raster effects listed before it from following the Transform effects after +/// it ([`followed`]): is it a visible Transform effect that makes copies or moves at random? +pub(crate) fn holds_back(e: &Effect) -> bool { + transform_step(e).is_none() +} + +/// What `e` does to the raster effects listed before it ([`followed`]): the linear part of its map +/// when it is a visible Transform effect that moves the art once, the identity when it is no +/// Transform effect, is hidden or has Transform Objects off (the art stays where it is), and `None` +/// when it makes copies or moves at random. +fn transform_step(e: &Effect) -> Option { + if e.id != "distort.transform" || !e.visible { + return Some(Affine::IDENTITY); + } + let p = merged_params(&e.id, &e.params); + if !flag(&p, "objects", true) { + return Some(Affine::IDENTITY); + } + let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0); + (copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO)) +} + +/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG +/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room +/// around the art. +fn follows(id: &str) -> bool { + crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE +} + +/// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as +/// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean +/// scale ([`crate::scale_effect`]), and a Drop Shadow's offset turns, reflects and scales with +/// `a`. Effects that [`follows`] leaves out stay as they are. +fn follow_transform(e: &mut Effect, a: Affine) { + if !follows(&e.id) { + return; + } + let offset = (e.id == "stylize.dropShadow").then(|| { + let p = merged_params(&e.id, &e.params); + a * Point::new(num(&p, "x", 7.0), num(&p, "y", 7.0)) + }); + if let Some(k) = vectorcraft_doc::Scaling::factor(a) { + crate::scale_effect(e, k); + } + if let Some(o) = offset.filter(|o| o.x.is_finite() && o.y.is_finite()) { + if !e.params.is_object() { + e.params = Value::Object(Map::new()); + } + e.params["x"] = json!(o.x); + e.params["y"] = json!(o.y); + } +} + /// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected) /// geometry's, in document units. Geometry effects are accounted for by evaluating them. pub fn outset(effects: &[Effect], bounds: Rect) -> f64 { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 483ac44d6e..8822548c29 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -604,7 +604,7 @@ fn as_art_opts( out.knockout = vectorcraft_doc::Knockout::Off; out.knockout_shape = n.knockout_shape; out.extras_mut().mask = n.extras().mask.clone(); - out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + out.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(out) } @@ -944,7 +944,7 @@ impl Step<'_> { // The copies of a path replace it by a group of them, the path included. let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len); if options.reach_appearance() { - let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, None); *used = used.minus(before).plus(made); if moved { // With Transform Objects off the path stays, and its mask takes the @@ -960,7 +960,7 @@ impl Step<'_> { } if separate { let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() }; - let made = copy_leaf(leaf, &path, m, copies, scaling, room); + let made = copy_leaf(leaf, &path, m, copies, scaling, room, None); *used = used.minus(before).plus(made); moved_ids.insert(leaf.id); short |= counts(leaf.id) && copied(leaf) <= copies; @@ -1245,8 +1245,9 @@ fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::S /// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its /// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them /// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform -/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after, -/// and `false` when the path kept its shape because the moved path would pass `budget`. +/// Objects off the geometry stays and the options apply once. The effects `kept` names keep their +/// values on the leaf and on each copy ([`keep_listed`]). Returns what the leaf costs after, and +/// `false` when the path kept its shape because the moved path would pass `budget`. fn transform_leaf( leaf: &mut Node, path: &PathData, @@ -1254,6 +1255,7 @@ fn transform_leaf( copies: usize, options: crate::distort::TransformOptions, budget: Budget, + kept: Kept, ) -> (Budget, bool) { let scaling = copy_scaling(options); if !options.objects { @@ -1266,6 +1268,7 @@ fn transform_leaf( if options.patterns { vectorcraft_doc::pattern::transform_pattern_paints(leaf, m); } + keep_listed(leaf, kept); return (Budget::of(path), true); } if copies == 0 { @@ -1275,9 +1278,10 @@ fn transform_leaf( return (Budget::of(path), false); } leaf.transform(m, scaling); + keep_listed(leaf, kept); return (Budget::of(&moved), true); } - (copy_leaf(leaf, path, m, copies, scaling, budget), true) + (copy_leaf(leaf, path, m, copies, scaling, budget, kept), true) } /// Does a Transform effect's copies of `art` copy it whole ([`copy_art`])? They do when `art` is a @@ -1423,9 +1427,10 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc:: /// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the /// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass /// `budget` with what each copy's own geometry effects make ([`effects_cost`]), and before one's -/// strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the copies cost, the original -/// included. -fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { +/// strokes would scale past [`MAX_STROKE_SCALE`]. The effects `kept` names keep their values on +/// each copy, before its cost is counted ([`keep_listed`]). Returns what the copies cost, the +/// original included. +fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget, kept: Kept) -> Budget { let mut made = Budget::of(path).plus(effects_cost(leaf, path)); let mut placements: Vec> = vec![Arc::new(leaf.clone())]; let mut xf = Affine::IDENTITY; @@ -1438,6 +1443,7 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling } let mut copy = leaf.clone(); copy.transform(xf, scaling); + keep_listed(&mut copy, kept); // Each copy's own geometry effects make a different amount, as the copy and its effects scale. let mut cost = Budget::of(&moved).plus(effects_cost(©, &moved)); // Each copy carries the art of its linked masks, moved with it. @@ -1584,9 +1590,12 @@ fn item_makes_piece(item: &AppearanceItem) -> bool { /// effects. In paint order, each such fill or stroke is a path of its own: the leaf's geometry after /// the geometry effects of that fill or stroke before its first such Transform effect, transformed /// as [`transform_leaf`] transforms an object with that effect's map, copies and options, so the -/// stroke's weight and dashes, the distances of its remaining effects and its pattern follow the -/// map. The reference box is that geometry's bounds and the seed is the leaf's id, as the renderer -/// draws a fill's or stroke's own effects. The geometry effects after the Transform effect then +/// stroke's weight and dashes, the distances of the effects before the Transform effect and its +/// pattern follow the map, and the raster effects before it that follow it take their followed +/// values ([`follow_piece`]). The effects after it keep their values and apply to what it made +/// ([`keep_listed`]), and with Transform Objects off so do the effects before it. The reference box +/// is that geometry's bounds and the seed is the leaf's id, as the renderer draws a fill's or +/// stroke's own effects. The geometry effects after the Transform effect then /// apply to the transformed path and its copies as one piece ([`transform_piece`]), and its other /// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's /// geometry between them, with their own effects. `max` is the budget of the whole art and `used` @@ -1659,9 +1668,16 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let params = merged_params(&fx[at].id, &fx[at].params); // Where each effect the piece keeps sits among the item's effects. let kept: Vec = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect(); + // A Transform effect after this one that makes copies or moves at random holds the raster + // effects before this one back from every Transform effect ([`crate::raster::followed`]). + let held_later = fx.get(at + 1..).unwrap_or_default().iter().any(crate::raster::holds_back); + // The effects before the Transform effect that it keeps, each raster effect following the + // geometry effects before it ([`crate::raster::without_geometry`]) unless held back. + let kept_before: Vec = + if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(before) }; if let Some(plugin) = painted_plugin(&fx[at]) { let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); *it.effects_mut() = rest; let mut one = Appearance { items: vec![it.clone()], ..Default::default() }; if carry.moves(&one) { @@ -1683,7 +1699,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt group.push(Arc::new(p)); } let mut p = Node::group(leaf.id, group); - transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), limit, used, doc); pieces.push(Arc::new(p)); continue; } @@ -1691,16 +1707,22 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); - *it.effects_mut() = rest; + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); + *it.effects_mut() = rest.clone(); let mut p = piece(vec![it]); if carry.moves(&p.appearance) { carry.apply(&mut p.appearance); } set_geometry(&mut p, g.clone(), rule); - let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room); + let after = kept_before.len(); + let keep = |k: usize| k >= after || !options.objects; + let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room, Some((&rest, &keep))); *used = used.plus(made); - transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); + let from = crate::raster::held_back(before); + if moved && !held_later { + follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]); + } + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -1717,27 +1739,45 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece: /// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's /// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of -/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with -/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. +/// the effect, at the effect's own distances. A Transform effect with Transform Patterns or Scale +/// Strokes & Effects on transforms each path as [`transform_leaf`] does, and leaves the values of +/// the effects after it, and with Transform Objects off those before it, as they are +/// ([`keep_listed`]). /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path -/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and -/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art -/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source, -/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the -/// piece was made from, and the index among that item's effects of each effect the piece kept, -/// which a deferred live-effect plug-in is listed by. -fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) { +/// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect +/// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before +/// the piece's effects that makes copies or moves at random holds back, and the effects before +/// such a Transform effect among the piece's effects. The geometry effects then +/// leave the fills and strokes; their other effects stay. The paths keep to `max` together with +/// the rest of the art (`used`), and the swatches and patterns painted pieces name must be in +/// `doc`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the +/// fill or stroke the piece was made from, and the index among that item's effects of each effect +/// the piece kept, which a deferred live-effect plug-in is listed by. +fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), held: usize, max: Budget, used: &mut Budget, doc: Option<&Document>) { let mut count = 0; for_each_leaf(p, &mut |l| { if let Some(item) = l.appearance.items.first() { count = count.max(item.effects().len()); } }); + // The effects before a Transform effect among the piece's effects that makes copies or moves at + // random are held back from the Transform effects before it too. + let mut held = held; + for_each_leaf(p, &mut |l| { + if let Some(last) = l.appearance.items.first().and_then(|item| item.effects().iter().rposition(crate::raster::holds_back)) { + held = held.max(last); + } + }); for j in 0..count { let Some(bounds) = p.geometric_bounds() else { break }; + let from = held.min(j); + let mut holds = false; for_each_leaf(p, &mut |leaf| { let Some(item) = leaf.appearance.items.first().cloned() else { return }; let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return }; + holds |= crate::raster::holds_back(&e); + // The effects before this one as they were before it moved the path. + let was = item.effects().get(from..j).unwrap_or_default(); let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. let before = Budget::of(&path); @@ -1754,8 +1794,12 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let (made, _) = transform_leaf(leaf, &path, map, copies, options, room); + let keep = |k: usize| k > j || (k < j && !options.objects); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, Some((item.effects(), &keep))); *used = used.minus(before).plus(made); + if moved { + follow_piece(leaf, was, from, &e); + } return; } let appearance = leaf.appearance.clone(); @@ -1772,7 +1816,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m carry.apply(&mut leaf.appearance); } set_geometry(leaf, out, rule); + follow_piece(leaf, was, from, &e); }); + if holds { + held = held.max(j); + } } for_each_leaf(p, &mut |leaf| { for item in &mut leaf.appearance.items { @@ -1781,6 +1829,53 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m }); } +/// The effects a Transform effect in a fill's or stroke's own list leaves as they were +/// ([`keep_listed`]): the list as it was before the Transform effect was applied, and which of its +/// indices keep their values. `None` for an object's own Transform effect. +type Kept<'a> = Option<(&'a [Effect], &'a dyn Fn(usize) -> bool)>; + +/// Put back on the fills and strokes of each path under `p` the effect `kept`'s list holds at each +/// index it takes, in place of what a Transform effect in their own list scaled or moved with its +/// options ([`transform_leaf`]). The effects after the Transform effect apply to the art it made, +/// and with Transform Objects off the effects before it keep their values too. A piece's fills and +/// strokes share one list: a live-effect plug-in that paints what it returns gives each paint a copy +/// of the fill or stroke ([`vectorcraft_plugins::painted::items`]). +fn keep_listed(p: &mut Node, kept: Kept) { + let Some((was, keep)) = kept else { return }; + for_each_leaf(p, &mut |leaf| { + for item in &mut leaf.appearance.items { + for (k, (e, old)) in item.effects_mut().iter_mut().zip(was).enumerate() { + if keep(k) { + e.clone_from(old); + } + } + } + }); +} + +/// Make the raster effects of each of `leaf`'s fills and strokes from index `from` on follow +/// Transform effect `t`, listed after them, once `t` has moved the leaf +/// ([`crate::raster::follow_one`]). The fills and strokes are copies of one fill or stroke (those a +/// painted plug-in returns, [`vectorcraft_plugins::painted::items`]), with the same effects. `was` +/// holds those effects of the first one as they were before `t` was applied: each one that +/// follows takes the value [`crate::raster::followed`] gives it, in place of what Scale Strokes & +/// Effects made of it, and the others keep theirs. +fn follow_piece(leaf: &mut Node, was: &[Effect], from: usize, t: &Effect) { + let mut follow = was.to_vec(); + if !crate::raster::follow_one(&mut follow, t) { + return; + } + for item in &mut leaf.appearance.items { + for (k, (new, old)) in follow.iter().zip(was).enumerate() { + if new != old + && let Some(e) = item.effects_mut().get_mut(from + k) + { + *e = new.clone(); + } + } + } +} + /// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in /// order. A group inside one of them is part of it. fn type_outline_bounds(n: &Node, out: &mut Vec>) { diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index ec9563f05c..20a66e9a83 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -8,7 +8,9 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, L use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds}; +use crate::solids::{ + Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, +}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -558,17 +560,11 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } children.push(Arc::new(surface)); // Later geometry acts on the projected art as a whole through container evaluation. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); + remaining.retain(|e| !is_revolve(&e.id)); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining.retain(|e| !is_geometry(&e.id)); + remaining = crate::without_geometry(&remaining); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { @@ -835,8 +831,7 @@ fn group_art(n: &Node, expand: bool) -> Option { // One shadow for every member's surface, behind them all. let mut children = children; children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 538d2a5028..a49829f50c 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -978,17 +978,10 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; children.extend(shadow.into_iter().chain(surfaces)); // Later geometry effects act on the projected art as a whole. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + remaining = crate::without_geometry(&n.appearance.effects); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in &paints { @@ -1024,14 +1017,28 @@ fn group_solid_art(n: &Node) -> Option { reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let (shadow, surfaces) = art_for(n, fx, &shapes)?; let children = shadow.into_iter().chain(surfaces).collect(); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); Some(art) } +/// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the +/// art it projects. Each raster effect before it follows the Transform effects among those +/// ([`crate::without_geometry`]), unless a Transform effect after it makes copies or moves at +/// random ([`crate::raster::holds_back`]); the effects after it stay as they are. +pub(crate) fn past_effect(effects: &[Effect], index: usize) -> Vec { + let (before, after) = effects.split_at(index.min(effects.len())); + let after = after.get(1..).unwrap_or_default(); + let kept: Vec = if after.iter().any(crate::raster::holds_back) { + before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() + } else { + crate::without_geometry(before) + }; + kept.into_iter().chain(after.iter().cloned()).collect() +} + /// One solid of a 3D object as a mesh, with the color it is painted. #[derive(Clone, Debug, PartialEq)] pub struct SolidMesh { diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index acb980204c..d188bcafd6 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -4,7 +4,8 @@ //! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner //! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by //! the inset; with [`ArrowAlign::Extend`] the path keeps its length and the tip sits the inset -//! past the end point. Heads added after the first ten are drawn in local units ([`local`]). +//! past the end point. Heads added after the first ten are drawn in local units ([`local`]), as are +//! custom heads, whose stroke ends halfway along their length. use std::borrow::Cow; @@ -37,8 +38,14 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone()); let cap = cap_extent(st.cap, st.width); let (hw0, hw1) = (st.arrow_weight(false), st.arrow_weight(true)); - let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, hw0, cap))); - let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, hw1, cap))); + let start = st.start_arrow.clone().zip(first).map(|(k, r)| { + let i = inset(&k, hw0, cap); + (k, r, i) + }); + let end = st.end_arrow.clone().zip(last).map(|(k, r)| { + let i = inset(&k, hw1, cap); + (k, r, i) + }); if start.is_none() && end.is_none() { return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] }; } @@ -56,7 +63,8 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> } else { (end_pt + outward * inset, outward) }; - heads.push(Arrow { kind, outline: shape(kind, tip, dir, hw), tip, dir, inset }); + let outline = shape(&kind, tip, dir, hw); + heads.push(Arrow { kind, outline, tip, dir, inset }); }; if let Some((k, r, i)) = start { place(k, r, true, i, hw0); @@ -181,12 +189,12 @@ pub(super) fn trims(st: &StrokeLayer) -> (f64, f64) { return (0.0, 0.0); } let cap = cap_extent(st.cap, st.width); - let inset_of = |kind: Option, end: bool| kind.map_or(0.0, |k| inset(k, st.arrow_weight(end), cap)); - (inset_of(st.start_arrow, false), inset_of(st.end_arrow, true)) + let inset_of = |kind: &Option, end: bool| kind.as_ref().map_or(0.0, |k| inset(k, st.arrow_weight(end), cap)); + (inset_of(&st.start_arrow, false), inset_of(&st.end_arrow, true)) } /// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end. -fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { +fn inset(kind: &Arrowhead, hw: f64, cap: f64) -> f64 { let l = len(hw); // Solid heads: halfway in, far enough that the cap stays behind the tip. let solid = |back: f64| (back / 2.0).max(cap); @@ -199,6 +207,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l), // The inner corner of the chevron: arm / sin(half angle). Arrowhead::ArrowOpen => (arm(hw) * (l * l + l * l / 4.0).sqrt() / (l / 2.0)).max(cap), + Arrowhead::Custom(c) => (c.stop() * hw).max(cap), _ => match local_stop(kind) { Stop::At(u) => (u * hw).max(cap), Stop::Wall(back) => hollow(back * hw), @@ -207,7 +216,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { } /// Outline of a `kind` head of weight `hw` with its tip at `tip`, pointing along `dir`. -fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { +fn shape(kind: &Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { let n = Vec2::new(-dir.y, dir.x); let l = len(hw); let half = l / 2.0; @@ -265,7 +274,7 @@ const HEX: f64 = 2.0 * 1.732_050_807_568_877_2; const LOCAL_TOL: f64 = 1e-3; /// Where the stroke ends under a head drawn in local units ([`local`]), in those units. -fn local_stop(kind: Arrowhead) -> Stop { +fn local_stop(kind: &Arrowhead) -> Stop { match kind { Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0), Arrowhead::HexagonOpen => Stop::Wall(HEX), @@ -288,8 +297,8 @@ fn local_stop(kind: Arrowhead) -> Stop { /// The outline of a head added after the first ten, in local units: x from the tip (0) back /// along the line (to at most 4; the line arrow's square-cut arms 4.71), y across it (−2 to 2, /// the line arrow −4 to 4; positive to the right of the direction the head points). Every shape -/// is generated here. -fn local(kind: Arrowhead) -> BezPath { +/// is generated here, and a custom head's outline is its own. +fn local(kind: &Arrowhead) -> BezPath { let mut p = BezPath::new(); let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>()); let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>(), ring(1.0)); @@ -385,6 +394,7 @@ fn local(kind: Arrowhead) -> BezPath { let t = FRAC_1_SQRT_2; poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]); } + Arrowhead::Custom(c) => p = c.path.to_bezpath(), // The first ten are drawn by `shape`. Arrowhead::Triangle | Arrowhead::TriangleOpen diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index 400f3b58b9..f174069c4e 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -134,6 +134,10 @@ impl Plan { fn fitted(mut segs: Vec, mut lens: Vec, closed: bool, period: f64) -> Self { let n = segs.len(); + // A subpath with no segments (a lone moveto) has no runs to fit. + if n == 0 { + return Self::exact(segs, lens, closed, period); + } // Corners: where segments with length meet at a kink (zero-length ones have no direction). let live: Vec = (0..n).filter(|&i| lens[i] > EPS).collect(); let mut corners: Vec = live.windows(2).filter(|w| kink(&segs[w[0]], &segs[w[1]])).map(|w| w[1]).collect(); diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index fc3f2eee48..1c79fd4805 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -280,7 +280,7 @@ fn no_heads_borrows_the_path_and_closed_paths_get_none() { #[test] fn tip_mode_shortens_the_stroke_by_the_head_and_puts_the_tip_on_the_end_point() { for kind in Arrowhead::ALL { - let st = arrows(kind, ArrowAlign::Tip, LineCap::Butt); + let st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Butt); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); let [h] = &p.heads[..] else { panic!("one head") }; @@ -335,7 +335,7 @@ fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() { #[test] fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() { for (kind, cap) in [(Arrowhead::CircleOpen, LineCap::Round), (Arrowhead::SquareOpen, LineCap::Square), (Arrowhead::TriangleOpen, LineCap::Butt)] { - let st = arrows(kind, ArrowAlign::Tip, cap); + let st = arrows(kind.clone(), ArrowAlign::Tip, cap); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); let h = &p.heads[0]; @@ -361,7 +361,7 @@ fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() { fn round_caps_never_poke_past_the_tip() { for kind in Arrowhead::ALL { for scale in [10.0, 100.0, 300.0] { - let mut st = arrows(kind, ArrowAlign::Tip, LineCap::Round); + let mut st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Round); st.arrow_scale = (scale, scale); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); @@ -403,8 +403,8 @@ fn degenerate_paths_and_patterns_neither_panic_nor_hang() { for pat in patterns { for off in [0.0, -7.0, 1e12, f64::INFINITY] { let st = stroke(2.0, |s| { - s.start_arrow = Some(kind); - s.end_arrow = Some(kind); + s.start_arrow = Some(kind.clone()); + s.end_arrow = Some(kind.clone()); s.arrow_align = align; s.cap = cap; s.dash = Some(dashed(pat, off)); diff --git a/crates/effects/src/stroke/tests_arrows.rs b/crates/effects/src/stroke/tests_arrows.rs index 4328048991..a32a70245b 100644 --- a/crates/effects/src/stroke/tests_arrows.rs +++ b/crates/effects/src/stroke/tests_arrows.rs @@ -50,7 +50,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { let (o, d) = (Point::ORIGIN, Point::new(2.0, 2.0)); assert!(cross((o, d), (Point::new(0.0, 2.0), Point::new(2.0, 0.0))) && !cross((o, d), (d, Point::new(4.0, 0.0))), "the check itself"); for kind in Arrowhead::ALL { - let h = head(kind); + let h = head(kind.clone()); assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn"); let b = h.outline.bounding_box(); let (reach, side) = (2.0 * kind.reach(), 2.0 * kind.half_width()); @@ -73,7 +73,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { #[test] fn the_stroke_ends_inside_every_head() { for kind in Arrowhead::ALL { - let h = head(kind); + let h = head(kind.clone()); // Just in front of where the line stops, on its centre line. let end = h.tip - h.dir * (h.inset - 0.1); assert_ne!(h.outline.winding(end), 0, "{kind:?}: the line ends outside the head (inset {})", h.inset); @@ -112,3 +112,30 @@ fn half_arrows_barb_on_their_own_side() { assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5); assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0); } + +#[test] +fn custom_heads_draw_their_own_art_at_the_tip() { + use vectorcraft_doc::CustomArrowhead; + use vectorcraft_geom::{PathData, SubPath}; + // From the tip back 3 units, 2 to the right of the line and 1 to the left. + let wedge = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true)); + let kind = Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), wedge))); + let h = head(kind.clone()); + // Weight 2: drawn back to x 94, right of +x is +y on a y-down page. + let b = h.outline.bounding_box(); + assert!((b.x0 - 94.0).abs() < 1e-9 && (b.x1 - 100.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.y1 - 4.0).abs() < 1e-9, "{b:?}"); + assert_eq!(h.outline.winding(Point::new(95.0, 1.0)), h.outline.winding(Point::new(95.0, 0.5))); + assert_ne!(h.outline.winding(Point::new(95.0, 1.0)), 0); + // The line stops halfway along the head. + assert!((h.inset - 3.0).abs() < 1e-9, "inset {}", h.inset); + // Extended, the tip sits the inset past the end point, and scaling scales the art. + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + (st.start_arrow, st.arrow_scale) = (Some(kind), (200.0, 100.0)); + let mut l = BezPath::new(); + l.move_to((0.0, 0.0)); + l.line_to((100.0, 0.0)); + let p = stroke_pieces(&l, &st); + let [s] = &p.heads[..] else { panic!("one head") }; + assert!((s.tip.x + 6.0).abs() < 1e-9 && s.dir.x < 0.0, "tip {:?}", s.tip); + assert!((s.outline.bounding_box().width() - 12.0).abs() < 1e-9); +} diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index b389cab853..cc69606aee 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -37,6 +37,20 @@ fn near(a: f64, b: f64, tol: f64) -> bool { // ---------------------------------------------------------------- dashes fitted to corners +/// A subpath with no segments (a lone moveto, as an HP-GL wedge of almost no radius gives) is +/// dashed fitted to corners without a panic, and the other subpaths keep their dashes. +#[test] +fn fitted_dashes_take_a_subpath_with_no_segments() { + let mut b = BezPath::new(); + b.move_to((5.0, 5.0)); + b.extend(line(100.0)); + let d = dash(&b, &fitted(&[10.0, 5.0], 0.0)).expect("dashed"); + assert!(moves(&d.path) > 1 && length(&d.path) > 0.0, "{:?}", d.path); + let mut lone = BezPath::new(); + lone.move_to((0.0, 0.0)); + assert!(dash(&lone, &fitted(&[10.0, 5.0], 0.0)).is_some_and(|d| d.path.elements().is_empty())); +} + #[test] fn every_corner_of_a_rectangle_falls_mid_dash() { // 100 × 50 with 12/6: the long sides hold 6 periods (scaled by 100/108), the short ones 3 diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs index 29a48343ed..6e141fd5b5 100644 --- a/crates/effects/src/stroke/tests_reach.rs +++ b/crates/effects/src/stroke/tests_reach.rs @@ -99,14 +99,27 @@ fn bounds_and_clicks_cover_what_every_stroke_paints() { } } +/// Custom heads: one wholly behind its tip, and a long one reaching as far in front of its tip as +/// behind it (its tip sits halfway along it past the end point with Extend). +fn custom_heads() -> Vec { + use vectorcraft_geom::{Point as P, SubPath}; + let head = |name: &str, pts: &[(f64, f64)]| { + let pts: Vec

= pts.iter().map(|&(x, y)| P::new(x, y)).collect(); + let path = PathData::single(SubPath::polyline(&pts, true)); + Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(name.into(), path))) + }; + vec![head("Wedge", &[(0.0, 0.0), (3.0, 2.0), (3.0, -1.0)]), head("Spindle", &[(-10.0, 0.0), (0.0, 2.0), (10.0, 0.0), (0.0, -2.0)])] +} + #[test] fn bounds_and_clicks_cover_every_arrowhead() { let bp = &paths()[0]; - for kind in Arrowhead::ALL { + for kind in Arrowhead::ALL.into_iter().chain(custom_heads()) { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for (scale, cap) in [(50.0, LineCap::Round), (100.0, LineCap::Butt), (300.0, LineCap::Square)] { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); - (st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = (Some(kind), Some(kind), align, (scale, scale), cap); + (st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = + (Some(kind.clone()), Some(kind.clone()), align, (scale, scale), cap); check(bp, &st, &format!("{kind:?} {align:?} {scale}%")); } } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index e63b38a462..88300586b3 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod rasterfollow; mod scale; mod transformfx; @@ -1251,6 +1252,96 @@ fn roughen_smooth_makes_no_cusps() { } } +#[test] +fn roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly() { + use vectorcraft_geom::AnchorKind; + use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let curve = PathData::single(SubPath::new( + vec![ + vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)), + vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b), + ], + false, + )); + let ellipse = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 50.0)); + // Two subpaths with lines and curves: a closed one whose last segment is a line, and an open + // one that ends with a line. + let (c, d, e, f) = (Point::new(100.0, 100.0), Point::new(200.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 100.0)); + let mixed = PathData::new(vec![ + SubPath::new( + vec![ + vectorcraft_geom::Anchor::corner(a), + vectorcraft_geom::Anchor::with_handles(Point::new(100.0, 0.0), Point::new(100.0, 0.0), Point::new(160.0, 20.0)), + vectorcraft_geom::Anchor::with_handles(c, Point::new(160.0, 80.0), c), + ], + true, + ), + SubPath::new( + vec![ + vectorcraft_geom::Anchor::with_handles(d, d, Point::new(220.0, 60.0)), + vectorcraft_geom::Anchor::with_handles(e, Point::new(280.0, 60.0), e), + vectorcraft_geom::Anchor::corner(f), + ], + false, + ), + ]); + for path in [square(), ellipse, curve, mixed] { + let all: Vec<_> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect(); + let off = |q: Point| all.iter().map(|c| c.nearest(q, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt(); + for params in + [json!({"size": 0}), json!({"size": 0, "points": "corner"}), json!({"size": 0, "relative": false, "points": "corner", "seed": 7})] + { + let out = run("distort.roughen", params.clone(), &path); + assert!(out.anchor_count() > path.anchor_count(), "{params}: the points are added"); + assert_eq!(out.subpaths.len(), path.subpaths.len(), "{params}"); + for (sp, got) in path.subpaths.iter().zip(&out.subpaths) { + assert_eq!(got.closed, sp.closed); + // Walk the result one original segment at a time: its pieces end on the segment's end anchor. + let mut at = 0; + for i in 0..sp.segment_count() { + let (orig, end) = (sp.segment(i), sp.anchors[(i + 1) % sp.anchors.len()]); + let start = got.anchors[at]; + let was = sp.anchors[i]; + assert_eq!((start.p, start.kind, start.has_in(), start.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}"); + let mut pieces = vec![]; + loop { + let piece = got.segment(at); + for j in 0..=8 { + let q = piece.eval(j as f64 / 8.0); + assert!(off(q) < 1e-6, "{params}: {q:?} is {} off the outline", off(q)); + } + pieces.push(piece.arclen(1e-6)); + at += 1; + if piece.p3.distance(end.p) < 1e-9 { + break; + } + // A point the effect added: a corner without handles on a line, a smooth + // point whose handles follow the curve. + let added = got.anchors[at]; + if sp.segment_is_line(i) { + assert_eq!((added.kind, added.has_in(), added.has_out()), (AnchorKind::Corner, false, false), "{params}"); + } else { + assert_eq!(added.kind, AnchorKind::Smooth, "{params}"); + let (hi, ho) = (added.h_in - added.p, added.h_out - added.p); + assert!(hi.cross(ho).abs() < 1e-6 * hi.hypot() * ho.hypot() && hi.dot(ho) < 0.0, "{params}: {added:?}"); + } + } + assert!(pieces.len() > 1, "{params}: segment {i} is split"); + assert!(close(pieces.iter().sum::(), orig.arclen(1e-6), 1e-3), "{params}: {pieces:?}"); + let (lo, hi) = pieces.iter().fold((f64::INFINITY, 0.0f64), |(lo, hi), &l| (lo.min(l), hi.max(l))); + assert!(hi - lo < 1e-3, "{params}: segment {i}'s pieces from {lo} to {hi}"); + } + assert_eq!(at, got.segment_count(), "{params}"); + if !sp.closed { + let (last, was) = (got.anchors[at], sp.anchors[sp.anchors.len() - 1]); + assert_eq!((last.p, last.kind, last.has_in(), last.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}"); + } + } + } + } +} + #[test] fn zigzag_ridge_count_and_amplitude() { let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square()); diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs new file mode 100644 index 0000000000..c674cf5041 --- /dev/null +++ b/crates/effects/src/tests/rasterfollow.rs @@ -0,0 +1,421 @@ +//! Raster effects listed before a Transform effect follow it: the Transform effect transforms +//! what they paint. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::{RasterFx, bake_document, expand_outlined, raster_effects}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn shadow(x: f64, y: f64, blur: f64) -> Effect { + fx("stylize.dropShadow", json!({"x": x, "y": y, "blur": blur})) +} + +fn transform(params: Value) -> Effect { + fx("distort.transform", params) +} + +/// The offset and blur of the first Drop Shadow `effects` draw. +fn drawn_shadow(effects: &[Effect]) -> (f64, f64, f64) { + match raster_effects(effects).first() { + Some(RasterFx::DropShadow { dx, dy, blur, .. }) => (*dx, *dy, *blur), + other => panic!("expected a drop shadow, got {other:?}"), + } +} + +fn close(a: (f64, f64, f64), b: (f64, f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 && (a.2 - b.2).abs() < 1e-9 +} + +/// A 50 pt red square with `effects`. +fn square(id: u64, effects: Vec) -> Node { + let mut n = Node::path( + NodeId(id), + shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + n.appearance.effects = effects; + n +} + +#[test] +fn a_transform_after_a_drop_shadow_scales_its_offset_and_blur() { + for strokes in [false, true] { + let effects = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200, "strokes": strokes}))]; + assert!(close(drawn_shadow(&effects), (20.0, 20.0, 8.0)), "Scale Strokes & Effects {strokes}: {:?}", drawn_shadow(&effects)); + } +} + +#[test] +fn a_drop_shadow_after_a_transform_keeps_its_offset() { + let effects = [transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 10.0, 4.0)]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +#[test] +fn the_offset_follows_an_uneven_scale_a_rotation_and_a_reflection() { + let uneven = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 100}))]; + assert!(close(drawn_shadow(&uneven), (20.0, 10.0, 4.0 * 2f64.sqrt())), "{:?}", drawn_shadow(&uneven)); + // A turn of 90° counterclockwise on screen takes an offset to the right to one upward. + let turned = [shadow(10.0, 0.0, 4.0), transform(json!({"rotate": 90}))]; + let (dx, dy, blur) = drawn_shadow(&turned); + assert!(dx.abs() < 1e-9 && (dy + 10.0).abs() < 1e-9 && (blur - 4.0).abs() < 1e-9, "{dx} {dy} {blur}"); + let reflected = [shadow(10.0, 0.0, 4.0), transform(json!({"reflectX": true}))]; + assert!(close(drawn_shadow(&reflected), (-10.0, 0.0, 4.0)), "{:?}", drawn_shadow(&reflected)); +} + +#[test] +fn transforms_after_the_shadow_apply_in_order() { + let effects = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"rotate": 90}))]; + let (dx, dy, _) = drawn_shadow(&effects); + assert!(dx.abs() < 1e-9 && (dy + 20.0).abs() < 1e-9, "{dx} {dy}"); + // Only the Transform after a shadow moves it. + let between = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), shadow(0.0, 10.0, 0.0)]; + let drawn = raster_effects(&between); + let offsets: Vec<(f64, f64)> = drawn + .iter() + .filter_map(|r| match r { + RasterFx::DropShadow { dx, dy, .. } => Some((*dx, *dy)), + _ => None, + }) + .collect(); + assert_eq!(offsets, vec![(20.0, 0.0), (0.0, 10.0)]); +} + +#[test] +fn transforms_that_leave_the_art_or_copy_it_leave_the_shadow() { + for params in [ + json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "copies": 1}), + json!({"scaleH": 200, "scaleV": 200, "random": true}), + ] { + let effects = [shadow(10.0, 10.0, 4.0), transform(params.clone())]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{params}: {:?}", drawn_shadow(&effects)); + } + let mut hidden = transform(json!({"scaleH": 200, "scaleV": 200})); + hidden.visible = false; + let effects = [shadow(10.0, 10.0, 4.0), hidden]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +/// A hidden shadow kept when the Transform effect is applied (Expand Appearance) draws as it would +/// have before, once it is shown. +#[test] +fn a_hidden_shadow_follows_the_transform_it_loses() { + let mut hidden = shadow(10.0, 10.0, 4.0); + hidden.visible = false; + let kept = crate::raster::without_geometry(&[hidden, transform(json!({"scaleH": 200, "scaleV": 200}))]); + assert_eq!(kept.len(), 1); + let mut shown = kept[0].clone(); + shown.visible = true; + assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0))); +} + +/// Rasterize keeps its room around the art: its image keeps its own pixel grid. +#[test] +fn rasterize_before_a_transform_keeps_its_options() { + let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6})); + let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::raster::followed(&effects)[0], rasterize); + assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]); +} + +/// An SVG filter keeps its options: it paints in its own user space. +#[test] +fn an_svg_filter_before_a_transform_keeps_its_options() { + let filter = fx(crate::SVG_FILTER, json!({"origin": [10, 10]})); + let effects = [filter.clone(), transform(json!({"scaleH": 200, "scaleV": 200, "rotate": 90}))]; + assert_eq!(crate::raster::followed(&effects)[0], filter); + assert_eq!(crate::raster::without_geometry(&effects), vec![filter]); +} + +/// The offset of every Drop Shadow drawn on `n` and the nodes under it, from the object's effects +/// and from each fill's and stroke's. +fn shadows_in(n: &Node) -> Vec<(f64, f64)> { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + for list in lists { + for r in raster_effects(list) { + if let RasterFx::DropShadow { dx, dy, .. } = r { + out.push((dx, dy)); + } + } + } + }); + out +} + +fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(p, q)| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9) +} + +/// A square whose fill carries `effects` of its own. +fn fill_square(id: u64, effects: Vec) -> Node { + let mut n = square(id, vec![]); + if let Some(item) = n.appearance.items.first_mut() { + *item.effects_mut() = effects; + } + n +} + +/// A fill's own Transform effect transforms the fill's raster effects before it: drawn as they are +/// (no options), drawn through the art the Transform Patterns or Scale Strokes & Effects option +/// makes, expanded and baked. Scale Strokes & Effects does not scale a shadow that follows the +/// Transform effect a second time. +#[test] +fn a_fills_shadow_follows_the_fills_own_transform() { + let cases = [ + (json!({"rotate": 90}), (0.0, -10.0)), + (json!({"rotate": 90, "strokes": true}), (0.0, -10.0)), + (json!({"scaleH": 200, "scaleV": 200, "strokes": true}), (20.0, 0.0)), + (json!({"scaleH": 200, "scaleV": 200, "patterns": true}), (20.0, 0.0)), + (json!({"reflectX": true, "patterns": true}), (-10.0, 0.0)), + ]; + for (params, want) in cases { + let n = fill_square(11, vec![shadow(10.0, 0.0, 0.0), transform(params.clone())]); + // Drawn and expanded through its art when an option makes some. + if crate::has_transform_art(&n) { + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[want]), "{params}: drawn {:?}", shadows_in(&art)); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(near(&shadows_in(&expanded), &[want]), "{params}: expanded {:?}", shadows_in(&expanded)); + } else { + assert!(near(&shadows_in(&n), &[want]), "{params}: drawn {:?}", shadows_in(&n)); + } + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = baked.node(NodeId(11)).unwrap(); + assert!(near(&shadows_in(b), &[want]), "{params}: baked {:?}", shadows_in(b)); + } +} + +/// A Transform effect after a fill's own Transform effect with Scale Strokes & Effects on turns the +/// shadow between them, which the first one leaves as it is; a Transform effect that moves at random +/// holds the shadow before it back from the Transform effects after it. +#[test] +fn a_fills_later_transform_turns_the_shadow_after_its_first() { + let n = fill_square( + 12, + vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90}))], + ); + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[(0.0, -10.0)]), "{:?}", shadows_in(&art)); + let held = fill_square( + 13, + vec![ + shadow(10.0, 0.0, 0.0), + transform(json!({"scaleH": 200, "scaleV": 200, "random": true, "strokes": true})), + transform(json!({"rotate": 90})), + ], + ); + let art = crate::reshape(None, &held, None).unwrap(); + let drawn = shadows_in(&art); + assert!(drawn.len() == 1 && drawn[0].0 > 0.0 && drawn[0].1.abs() < 1e-9, "{drawn:?}"); +} + +/// A fill's own Transform effect with Transform Patterns or Scale Strokes & Effects on leaves the +/// fill's raster effects after it as they are: they paint around the transformed art, its copies +/// included. With Transform Objects off it leaves the raster effects before it as they are too. +/// Drawn, expanded and baked alike. +#[test] +fn a_fills_own_transform_leaves_the_shadow_after_it() { + let cases = [ + vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 120, "copies": 1, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}))], + // A later Transform effect with an option leaves the shadow after it as well. + vec![ + transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})), + transform(json!({"scaleH": 150, "scaleV": 150, "strokes": true})), + shadow(10.0, 0.0, 0.0), + ], + ]; + for effects in cases { + let n = fill_square(18, effects.clone()); + let art = crate::reshape(None, &n, None).unwrap(); + let drawn = shadows_in(&art); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{effects:?}: drawn {drawn:?}"); + let expanded = shadows_in(&expand_outlined(None, &n, None).unwrap()); + assert!(!expanded.is_empty() && near(&expanded, &vec![(10.0, 0.0); expanded.len()]), "{effects:?}: expanded {expanded:?}"); + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = shadows_in(baked.node(NodeId(18)).unwrap()); + assert!(!b.is_empty() && near(&b, &vec![(10.0, 0.0); b.len()]), "{effects:?}: baked {b:?}"); + } +} + +/// A geometry effect after a fill's own Transform effect with Scale Strokes & Effects on reshapes +/// the transformed art by its own distance, as it does after an object's Transform effect. +#[test] +fn a_fills_own_transform_leaves_the_offset_after_it() { + let offset = fx("path.offsetPath", json!({"offset": 5})); + let scaled = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + let n = fill_square(19, vec![scaled.clone(), offset.clone()]); + let on_fill = crate::reshape(None, &n, None).unwrap(); + let on_object = crate::reshape(None, &square(20, vec![scaled, offset]), None).unwrap(); + let (fill, object) = (on_fill.geometric_bounds().unwrap(), on_object.geometric_bounds().unwrap()); + // The 50 pt square scaled to 100 pt about its center, then offset by 5 pt on each side. + assert!((fill.width() - 110.0).abs() < 1e-6 && (fill.height() - 110.0).abs() < 1e-6, "{fill:?}"); + assert!((fill.width() - object.width()).abs() < 1e-6 && (fill.x0 - object.x0).abs() < 1e-6, "{fill:?} {object:?}"); + let expanded = expand_outlined(None, &n, None).unwrap().geometric_bounds().unwrap(); + assert!((expanded.width() - 110.0).abs() < 1e-6 && (expanded.x0 - fill.x0).abs() < 1e-6, "expanded {expanded:?}"); +} + +/// The SVG filters on `n` and the nodes under it, from the object's effects and from each fill's +/// and stroke's. +fn filters_in(n: &Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + out.extend(lists.flatten().filter(|e| e.id == crate::SVG_FILTER).cloned()); + }); + out +} + +/// An SVG filter after a fill's own Transform effect with Scale Strokes & Effects on keeps its +/// scale and the origin of its user space, as it does after the same Transform effect without +/// options and after an object's own Transform effect. +#[test] +fn a_fills_own_transform_leaves_the_svg_filter_after_it() { + let filter = fx(crate::SVG_FILTER, json!({"scale": 1.0, "origin": [10.0, 10.0]})); + for params in [ + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "patterns": true}), + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}), + ] { + let art = crate::reshape(None, &fill_square(21, vec![transform(params.clone()), filter.clone()]), None).unwrap(); + let drawn = filters_in(&art); + assert!(!drawn.is_empty() && drawn.iter().all(|e| *e == filter), "{params}: {drawn:?}"); + } + let object = square(22, vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true})), filter.clone()]); + let art = crate::reshape(None, &object, None).unwrap(); + assert!(!filters_in(&art).is_empty() && filters_in(&art).iter().all(|e| *e == filter), "{:?}", filters_in(&art)); +} + +/// The 3D effects keep the shadow before a Transform effect that shapes the art they project, the +/// object's and its fill's 3D effect alike. +#[test] +fn a_shadow_before_a_transform_follows_it_into_3d_art() { + let before = vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let mut on_object = before.clone(); + on_object.push(fx(three_d, json!({}))); + let projected = art(&square(14, on_object)).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the object: {:?}", shadows_in(&projected)); + let mut on_fill = square(15, before.clone()); + if let Some(item) = on_fill.appearance.items.first_mut() { + *item.effects_mut() = vec![fx(three_d, json!({}))]; + } + let projected = art(&on_fill).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the fill: {:?}", shadows_in(&projected)); + } +} + +#[test] +fn glows_blurs_and_feathers_scale_with_the_transform_after_them() { + let effects = [ + fx("stylize.outerGlow", json!({"blur": 5})), + fx("stylize.innerGlow", json!({"blur": 5})), + fx("stylize.feather", json!({"radius": 5})), + fx("blur.gaussian", json!({"radius": 5})), + transform(json!({"scaleH": 50, "scaleV": 50})), + ]; + let sizes: Vec = raster_effects(&effects) + .iter() + .map(|r| match r { + RasterFx::OuterGlow { blur, .. } | RasterFx::InnerGlow { blur, .. } => *blur, + RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius, .. } => *radius, + other => panic!("unexpected {other:?}"), + }) + .collect(); + assert_eq!(sizes, vec![2.5; 4]); + // How far the shadow paints past the object follows too. + let shadowed = [shadow(10.0, 10.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::outset(&shadowed, Rect::new(0.0, 0.0, 100.0, 100.0)), 20.0); +} + +#[test] +fn pixel_filter_sizes_scale_with_the_transform_after_them() { + let effects = [ + fx("artistic.coloredPencil", json!({"pencilWidth": 5})), + fx("artistic.paintDaubs", json!({"brushSize": 10})), + transform(json!({"scaleH": 200, "scaleV": 200})), + ]; + let drawn = raster_effects(&effects); + assert!(matches!(drawn.first(), Some(RasterFx::Pixel(crate::PixelFx::ColoredPencil { width, .. })) if (width - 10.0).abs() < 1e-9), "{drawn:?}"); + assert!(matches!(drawn.get(1), Some(RasterFx::Pixel(crate::PixelFx::PaintDaubs { size, .. })) if (size - 20.0).abs() < 1e-9), "{drawn:?}"); +} + +/// A Transform effect after a 3D effect that makes copies or moves at random holds back the shadow +/// before the 3D effect, as it does without the 3D effect. +#[test] +fn a_copying_transform_after_3d_holds_the_shadow_back() { + for later in [json!({"copies": 1}), json!({"scaleH": 150, "scaleV": 150, "random": true})] { + let plain = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(later.clone())]; + assert!(close(drawn_shadow(&plain), (10.0, 0.0, 0.0)), "{later}: {:?}", drawn_shadow(&plain)); + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let effects = vec![plain[0].clone(), plain[1].clone(), fx(three_d, json!({})), plain[2].clone()]; + let projected = art(&square(16, effects)).unwrap(); + let drawn = shadows_in(&projected); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{three_d} {later}: {drawn:?}"); + } + } +} + +/// In a fill's own effects, a Transform effect that makes copies after a Transform effect with +/// Transform Patterns on holds back the shadow before both, and before a Transform effect between +/// them. +#[test] +fn a_fills_copying_transform_holds_back_the_shadow_before_an_earlier_transform() { + let patterns = transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})); + let copies = transform(json!({"copies": 1})); + let lists = [ + vec![shadow(10.0, 0.0, 0.0), patterns.clone(), copies.clone()], + vec![shadow(10.0, 0.0, 0.0), patterns, transform(json!({"rotate": 90})), copies], + ]; + for effects in lists { + let n = fill_square(17, effects.clone()); + let art = crate::reshape(None, &n, None).unwrap(); + let drawn = shadows_in(&art); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{} effects: {drawn:?}", effects.len()); + } +} + +#[test] +fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() { + let effects = vec![shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + // A path drawn through its art (Scale Strokes & Effects on), expanded. + let mut with_options = effects.clone(); + with_options[1] = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + let n = square(7, with_options); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(close(drawn_shadow(&expanded.appearance.effects), (20.0, 20.0, 8.0)), "{:?}", expanded.appearance.effects); + // A path and a group, baked for export. + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, square(8, effects.clone())).unwrap(); + let mut g = Node::group(NodeId(9), vec![std::sync::Arc::new(square(10, vec![]))]); + g.appearance.effects = effects.clone(); + d.insert(Some(layer), 1, g).unwrap(); + let baked = bake_document(&d).unwrap(); + for id in [8, 9] { + let b = baked.node(NodeId(id)).unwrap(); + assert!(!b.appearance.effects.iter().any(|e| e.id == "distort.transform"), "{id}: {:?}", b.appearance.effects); + assert!(close(drawn_shadow(&b.appearance.effects), (20.0, 20.0, 8.0)), "{id}: {:?}", b.appearance.effects); + } +} diff --git a/crates/effects/src/tests/transformfx.rs b/crates/effects/src/tests/transformfx.rs index de69116b79..1046f3c437 100644 --- a/crates/effects/src/tests/transformfx.rs +++ b/crates/effects/src/tests/transformfx.rs @@ -222,7 +222,7 @@ fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() { } #[test] -fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects() { +fn a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects() { let mut n = stroke_with_effect(json!({})); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { f.effects.push(transform(json!({"scaleH": 300, "scaleV": 300, "strokes": true}))); @@ -231,10 +231,10 @@ fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects let art = reshape(None, &n, None).unwrap(); let fill = leaves(&art).into_iter().find(|p| p.appearance.stroke().is_none()).unwrap(); let AppearanceItem::Fill(f) = &fill.appearance.items[0] else { panic!("not a fill") }; - // The Offset Path, scaled from 5 to 15, applies to the transformed fill's path. + // The Offset Path applies to the transformed fill's path at its own 5 pt. assert!(f.effects.is_empty(), "{:?}", f.effects); let w = fill.geometric_bounds().unwrap().width(); - assert!(close(w, 330.0), "{w}"); + assert!(close(w, 310.0), "{w}"); } #[test] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a507686959..1aadd228a1 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -4,6 +4,7 @@ pub mod actionset; mod addons; mod apiref; pub(crate) mod appearance; +pub mod arrowheads; mod assets; mod attributes; pub mod brushlib; @@ -297,6 +298,7 @@ pub fn command_specs() -> &'static [CommandSpec] { // Split out of `paint` (M3.8); kept next to it so lists in registry order (the command // palette, `engine.commands`) show them where they always were. v.extend(stroke::specs()); + v.extend(arrowheads::specs()); v.extend(appearance::specs()); v.extend(style::specs()); v.extend(swatch::specs()); diff --git a/crates/engine/src/cmd/addons/color/legend.rs b/crates/engine/src/cmd/addons/color/legend.rs index e2f38a7a17..e6942378be 100644 --- a/crates/engine/src/cmd/addons/color/legend.rs +++ b/crates/engine/src/cmd/addons/color/legend.rs @@ -137,7 +137,7 @@ fn legend(s: &mut Session, p: &Value) -> Result { let parent = st.target_parent()?; let text_size = (size / 7.0).clamp(4.0, 12.0); let pad = (size / 12.0).clamp(1.0, 8.0); - let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size })); + let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size }), C)?; let count = swatches.len(); // Each chip's fill links to its swatch, as paint.setFill {swatch} links art: editing a global or // spot swatch then recolours the legend too. diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs index b2a9d74f83..bfe3d2f4ef 100644 --- a/crates/engine/src/cmd/addons/image.rs +++ b/crates/engine/src/cmd/addons/image.rs @@ -20,6 +20,7 @@ use vectorcraft_geom::Affine; use super::super::fileio::{MAX_RASTER_ALLOC, ppi}; use super::super::menucmds::has_image; +use super::super::paint::{PROCESS_ON_TRANSPARENCY, gray_colorized_with_process}; use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param}; /// Remove Background's command. @@ -39,7 +40,7 @@ pub fn specs() -> Vec { "Remove Background…", ["Object", "Plug-ins", "Image Tools"], None, - "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", + "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a result with no pixel left, and a grayscale image colorized with a process color (which can't colorize gray pixels with transparency) are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", has_image, remove_background )] @@ -182,8 +183,14 @@ fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut De // Every image is checked before any is decoded. let mut images = vec![]; for id in ids { - let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; + let Some(n) = st.doc.node(id) else { continue }; + let NodeKind::Image(im) = &n.kind else { continue }; let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; + // The result has an alpha channel, which a grayscale image colorized with a process color + // can't take. + if gray_colorized_with_process(&st.doc, n).is_some() { + return Err(bad(C, PROCESS_ON_TRANSPARENCY)); + } // The preview stands in for a file that hasn't been read, or that is kept at its preview. if im.link.is_some() && blob.is_proxy() { return Err(bad(C, "the linked image shows its preview: embed or relink it first")); diff --git a/crates/engine/src/cmd/addons/image/tests.rs b/crates/engine/src/cmd/addons/image/tests.rs index df27180e82..d57f58046f 100644 --- a/crates/engine/src/cmd/addons/image/tests.rs +++ b/crates/engine/src/cmd/addons/image/tests.rs @@ -1,8 +1,9 @@ use std::io::Cursor; use std::sync::Arc; -use image::{DynamicImage, ImageBuffer, Luma, Rgb}; +use image::{DynamicImage, ImageBuffer, Luma, LumaA, Rgb}; use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind}; use vectorcraft_geom::Affine; @@ -333,6 +334,54 @@ fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() { assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535])); } +/// A process color can't colorize a grayscale image with transparency, so a gray image colorized +/// with one keeps its pixels. Black, white and spot colors can, so those images are cleared. +#[test] +fn a_gray_image_colorized_with_a_process_color_is_refused() { + let gray = || { + png(DynamicImage::ImageLuma8(ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }])))) + }; + let (mut s, id) = open(&gray()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the fill colorizes the image"); + let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let e = s.execute(C, &json!({})).unwrap_err(); + assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "the document and its undo steps are unchanged"); + // Another image that already has an alpha channel and a process colorize color, as an opened + // file can hold, neither lets this one through nor stops a run on an image that can be cleared. + let held = ImageBlob::png(png(DynamicImage::ImageLumaA8(ImageBuffer::from_pixel(2, 2, LumaA([128u8, 100]))))); + let other = place_linked(&mut s, "held", held, (2, 2)); + s.edit("Embed and colorize", |d, _| { + let n = d.node_mut(other).unwrap(); + n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0)))); + if let NodeKind::Image(im) = &mut n.kind { + im.link = None; + } + Ok(()) + }) + .unwrap(); + let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let e = s.execute(C, &json!({"ids": [id.0]})).unwrap_err(); + assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "with another image refused already, nothing changes"); + s.execute("paint.setFill", &json!({"color": "#000000", "ids": [id.0]})).unwrap(); + assert_eq!(s.execute(C, &json!({"ids": [id.0]})).unwrap()["changed"], 1, "an image colorized with black is cleared beside the other"); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!(out.get_pixel(0, 0).0[1], 0, "the background is cleared"); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the image stays colorized"); + for fill in [json!({"color": "#000000"}), json!({"color": "#ffffff"}), json!({"swatch": "Ink"})] { + let (mut s, id) = open(&gray()); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} colorizes the image"); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!(out.get_pixel(0, 0).0[1], 0, "{fill}: the background is cleared"); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} still colorizes the image"); + } +} + /// The ICC profile the colors are in stays with them. #[test] fn the_color_profile_is_kept() { diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 537e6bfe75..10a00b1b45 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -139,7 +139,8 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result { let mut run = 0; for (i, at) in chars.into_iter().enumerate() { let random = if shape == "random" { unit(&mut rng) } else { 0.0 }; - let value = from + (to - from) * amount(shape, i, n, random); + // Rounding can carry a value a hair past the range's ends. + let value = (from + (to - from) * amount(shape, i, n, random)).clamp(lo, hi); let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?; change.localize(&t, C)?; while ends.get(run).is_some_and(|&e| e <= at) { diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs new file mode 100644 index 0000000000..0aeda59731 --- /dev/null +++ b/crates/engine/src/cmd/arrowheads.rs @@ -0,0 +1,453 @@ +//! Custom arrowheads (Stroke panel menu › Edit Arrowheads…). The arrowheads file is a native +//! document kept next to the preferences ([`Session::attach_arrowheads_file`]). Each of its symbols +//! is one arrowhead, listed by the symbol's name after the built-in heads in the Stroke panel's +//! arrowhead menus. A head points right (+x): the symbol's registration point is its tip, and the +//! art's height (its extent across the line) is drawn as wide as the built-in heads, four times +//! the head's weight. A head covers what its art paints: the visible fills, and the visible +//! strokes outlined, of the paths, compound paths and groups in it, and its gradient meshes, with +//! their geometry effects applied and clip groups clipped. A head is one solid shape in the stroke's paint: art at 0 % +//! opacity is left out, and other opacities, blend modes and opacity masks do not change the +//! shape. Saving the file reloads the heads. A stroke that uses a custom head carries a copy of its art +//! ([`vectorcraft_doc::CustomArrowhead`]), so its document draws it anywhere. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Node, NodeId, NodeKind, Symbol, SymbolRegistration}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath}; +use vectorcraft_pathops::BoolOp; + +use super::{CommandSpec, Result, Session, always, bad, cmd}; + +/// The arrowheads file's name, in the folder of the preferences. +pub const FILE_NAME: &str = "Arrowheads.vectorcraft"; +/// The largest arrowheads file read, and the most its document or its images may unpack to when +/// compressed. +pub const MAX_FILE_BYTES: u64 = 64 << 20; +/// The most heads read from the file; symbols past it are skipped. +pub const MAX_HEADS: usize = 256; +/// The most anchors a symbol's art, and the shapes it paints, may have; a symbol past it is skipped. +pub const MAX_ART_ANCHORS: usize = 16 * CustomArrowhead::MAX_ANCHORS; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "stroke.arrowheads.list", + "Arrowheads", + [], + None, + "{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus", + always, + list + ), + cmd!( + "stroke.arrowheads.edit", + "Edit Arrowheads", + [], + None, + "{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}", + has_file, + edit + ), + cmd!( + "stroke.arrowheads.reload", + "Reload Arrowheads", + [], + None, + "{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}", + has_file, + reload + ), + ] +} + +/// The custom arrowheads read from the arrowheads file. +#[derive(Default)] +pub struct Arrowheads { + path: Option, + heads: Vec>, + skipped: Vec<(String, String)>, + error: Option, +} + +impl Arrowheads { + /// The arrowheads file's path; `None` without one (the web, runs without preferences). + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + /// The heads, in the order of the file's symbols. + pub fn heads(&self) -> &[Arc] { + &self.heads + } + /// Head `name`. + pub fn get(&self, name: &str) -> Option> { + self.heads.iter().find(|h| h.name == name).cloned() + } + fn set(&mut self, read: Read) { + (self.heads, self.skipped) = (read.heads.into_iter().map(Arc::new).collect(), read.skipped); + self.error = None; + } +} + +/// The heads a document holds, and the symbols that are not heads with the reason. +#[derive(Debug, Default)] +pub struct Read { + pub heads: Vec, + pub skipped: Vec<(String, String)>, +} + +/// The heads of document `doc`: one per symbol, up to [`MAX_HEADS`] (see the module docs). +pub fn heads_of(doc: &Document) -> Read { + let mut d = doc.clone(); + let brushes = vectorcraft_brush::library(&d); + let mut out = Read::default(); + for (i, sym) in doc.symbols.iter().enumerate() { + if i >= MAX_HEADS { + out.skipped.push((sym.name.clone(), format!("only the first {MAX_HEADS} symbols are read"))); + continue; + } + if out.heads.iter().any(|h| h.name == sym.name) { + out.skipped.push((sym.name.clone(), "another symbol has this name".into())); + continue; + } + match head(&mut d, &brushes, sym) { + Ok(h) => out.heads.push(h), + Err(why) => out.skipped.push((sym.name.clone(), why.into())), + } + } + out +} + +/// Symbol `sym` of `d` as a head. +fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result { + const TOO_DETAILED: &str = "its art is too long or too detailed for a head"; + const UNITED: &str = "its shapes could not be combined into one"; + if art_anchors(&sym.art) > MAX_ART_ANCHORS { + return Err(TOO_DETAILED); + } + let art = baked_art(d, sym); + let mut shapes = Shapes::default(); + painted(d, brushes, &art, &mut shapes); + if shapes.too_detailed { + return Err(TOO_DETAILED); + } + if shapes.failed { + return Err(UNITED); + } + let area = shapes.united().ok_or(UNITED)?; + let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?; + let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height()); + // Head units: x back from the tip, y across to the right of +x, 4 units to the art's height. + let k = 4.0 / b.height(); + let local = Affine::new([-k, 0.0, 0.0, k, k * tip.x, -k * tip.y]); + let h = CustomArrowhead::new(sym.name.clone(), area.transformed(local)); + if h.path.subpaths.is_empty() { + return Err(TOO_DETAILED); + } + Ok(h) +} + +/// The anchors of the paths in `n` and of the pieces of its gradient meshes ([`mesh_anchors`]), its +/// members' included. +fn art_anchors(n: &Node) -> usize { + let own = match &n.kind { + NodeKind::Mesh(m) => mesh_anchors(m), + _ => n.path_data().map_or(0, PathData::anchor_count), + }; + n.children().map_or(own, |c| c.iter().fold(own, |sum, c| sum.saturating_add(art_anchors(c)))) +} + +/// The art of symbol `sym` of `d` with every geometry effect in it applied (its objects', fills', +/// strokes' and groups'), as the canvas draws it: the art baked as for export, on a copy of `d`. +fn baked_art(d: &Document, sym: &Symbol) -> Node { + // A plain new layer, so the file's own layers' effects stay out of the art. + let mut tmp = d.clone(); + tmp.layers = Document::new(1.0, 1.0).layers; + let Some(children) = tmp.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) else { return (*sym.art).clone() }; + *children = vec![sym.art.clone()]; + let baked = vectorcraft_render::effects::bake_document(&tmp); + baked.as_ref().and_then(|b| b.layers.first()?.children()?.first().map(|c| (**c).clone())).unwrap_or_else(|| (*sym.art).clone()) +} + +/// The shapes a symbol's art paints, up to [`MAX_ART_ANCHORS`] anchors in all. +#[derive(Default)] +struct Shapes { + list: Vec<(PathData, FillRule)>, + anchors: usize, + /// More anchors were asked for than fit; the shapes past the limit were not made. + too_detailed: bool, + /// A clip group's art could not be combined or clipped, so a shape it paints is missing. + failed: bool, +} + +impl Shapes { + /// Whether `anchors` more fit; when they do not, the shapes are too detailed. + fn room(&mut self, anchors: usize) -> bool { + self.too_detailed |= self.anchors.saturating_add(anchors) > MAX_ART_ANCHORS; + !self.too_detailed + } + fn push(&mut self, shape: (PathData, FillRule)) { + if self.room(shape.0.anchor_count()) { + self.anchors += shape.0.anchor_count(); + self.list.push(shape); + } + } + /// The shapes united, or `None` when one of them could not be (the union would miss it). + fn united(&self) -> Option { + let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect(); + vectorcraft_pathops::try_unite_all(&refs).ok() + } + /// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one. + fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) { + if inside.too_detailed || !self.room(inside.anchors) { + self.too_detailed = true; + return; + } + self.failed |= inside.failed; + let clip = |art: PathData| { + vectorcraft_pathops::try_boolean(&art, FillRule::NonZero, outline, rule, BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION).ok() + }; + let Some(clipped) = inside.united().and_then(clip) else { + self.failed = true; + return; + }; + if !clipped.subpaths.is_empty() { + self.push((clipped, FillRule::NonZero)); + } + } +} + +/// The shapes the shown paths and compound paths in `n` (baked art) paint: their filled areas and +/// their strokes' outlines, its groups' members included (baking has already made a group's own +/// fills and strokes art among its members), and the areas its gradient meshes cover. A clip +/// group's clipped art is clipped to +/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas; a group used as +/// the clipping path paints nothing ([`clip_paint`]). Hidden or fully +/// transparent art is left out. +fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + fills(n, out); + // The outline holds a copy of the path for each fill too, which adds nothing new. + if let Some(o) = super::pathops::outline_strokes(d, brushes, n) { + fills_in(&o, out); + } + } + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + clip_paint(clip).iter().for_each(|part| painted(d, brushes, part, out)); + // Nothing to clip by hides the clipped art, as on the canvas. + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| painted(d, brushes, c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| painted(d, brushes, c, out)), + NodeKind::Mesh(_) => mesh_area(n, out), + _ => {} + } +} + +/// What clipping path `clip` paints ([`Node::clip_paint`]): its fills and its strokes, each as a +/// copy of it keeping only those. A group or another object that does not paint as a clipping +/// path gives neither. +fn clip_paint(clip: &Node) -> Vec { + let paint = clip.clip_paint(); + [paint.fill, paint.stroke].into_iter().flatten().collect() +} + +/// The areas the shown paths and gradient meshes in `n` (an outlined stroke) fill, clipped by its +/// clip groups, whose clipping paths' own fills paint unclipped. A stroke with a gradient along or +/// across it is outlined as gradient meshes clipped to the stroke's outline. +fn fills_in(n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Mesh(_) => mesh_area(n, out), + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + clip_paint(clip).iter().for_each(|part| fills_in(part, out)); + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| fills_in(c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + _ => match n.children() { + Some(children) => children.iter().for_each(|c| fills_in(c, out)), + None => fills(n, out), + }, + } +} + +/// The anchors [`mesh_area`] makes at most of mesh `m`: its outline's, one a patch along each side, +/// when no point of it is fully transparent, else four by four quads of four anchors a patch. +fn mesh_anchors(m: &vectorcraft_doc::GradientMesh) -> usize { + let (rows, cols) = (m.rows as usize, m.cols as usize); + if opaque(m) { rows.saturating_add(cols).saturating_mul(2) } else { rows.saturating_mul(cols).saturating_mul(64) } +} + +fn opaque(m: &vectorcraft_doc::GradientMesh) -> bool { + m.points.iter().all(|p| p.opacity > 0.0) +} + +/// The area gradient mesh `n` paints: its outline when no point of it is fully transparent, else +/// the pieces of it that are not fully transparent (four by four quads a patch). A mesh whose +/// pieces do not fit in `out` makes the shapes too detailed. +fn mesh_area(n: &Node, out: &mut Shapes) { + let NodeKind::Mesh(m) = &n.kind else { return }; + if !out.room(mesh_anchors(m)) { + return; + } + if opaque(m) { + let outline = m.outline(); + if !outline.subpaths.is_empty() { + out.push((outline, FillRule::NonZero)); + } + return; + } + for q in m.quads(4) { + if q.opacity > 0.0 || q.corners.iter().any(|(_, o)| *o > 0.0) { + out.push((PathData::single(SubPath::polyline(&q.pts, true)), FillRule::NonZero)); + } + } +} + +/// The area path `n` fills, when a shown fill paints it. +fn fills(n: &Node, out: &mut Shapes) { + let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 && !f.paint.is_none()); + if n.appearance.items.iter().any(paints) + && let Some(shape) = super::pathops::node_path(n) + { + out.push(shape); + } +} + +/// The document a missing arrowheads file starts as: one example head, a solid triangle. +pub fn starter() -> Document { + let mut doc = Document::new(612.0, 792.0); + doc.title = "Arrowheads".into(); + let name = "Example Arrowhead"; + let tri = SubPath::polyline(&[Point::new(-10.0, -8.0), Point::new(10.0, 0.0), Point::new(-10.0, 8.0)], true); + let art = Node::path(NodeId(0), PathData::single(tri), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0)); + let art = doc.reid(&art); + doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); + // The tip is the right edge's middle. + doc.set_symbol_registration(name, SymbolRegistration(5)); + let id = doc.alloc_id(); + let instance = Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate((306.0, 396.0)) }); + let layer = doc.layers.first().map(|l| l.id); + let _ = doc.insert(layer, 0, instance); + doc +} + +impl Session { + /// Keep the custom arrowheads in the file at `path` and read the heads it holds (none while it + /// is missing; an unreadable file is reported by `stroke.arrowheads.list`). + pub fn attach_arrowheads_file(&mut self, path: String) { + self.arrowheads.path = Some(path); + self.reload_arrowheads(); + } + + /// Read the arrowheads file again. + fn reload_arrowheads(&mut self) { + let Some(path) = self.arrowheads.path.clone() else { return }; + match read(&path) { + Ok(Some(doc)) => self.arrowheads.set(heads_of(&doc)), + Ok(None) => self.arrowheads.set(Read::default()), + Err(e) => { + self.arrowheads.set(Read::default()); + self.arrowheads.error = Some(e); + } + } + } + + /// A document was saved to `path`: when that is the arrowheads file, its heads are read back + /// from what was written. + pub(crate) fn arrowheads_saved(&mut self, path: &str) { + if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) { + self.reload_arrowheads(); + } + } +} + +fn same_file(a: &str, b: &str) -> bool { + let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p)); + std::path::Path::new(a) == std::path::Path::new(b) || canon(a) == canon(b) +} + +/// The document in the arrowheads file at `path`; `None` when there is no file. +fn read(path: &str) -> std::result::Result, String> { + let size = match std::fs::metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("{path}: {e}")), + Ok(m) if !m.is_file() => return Err(format!("{path}: not a file")), + Ok(m) => m.len(), + }; + if size > MAX_FILE_BYTES { + return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20)); + } + let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?; + // A compressed file's document, and its images, may unpack to no more than an uncompressed + // file may hold. + let parse = || vectorcraft_format::load_file_capped(&bytes, MAX_FILE_BYTES).map(|f| f.doc).map_err(|e| format!("{path}: {e}")); + crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some) +} + +fn has_file(s: &Session) -> std::result::Result<(), String> { + if s.arrowheads.path.is_some() { Ok(()) } else { Err("no arrowheads file here".into()) } +} + +fn skipped(s: &Session) -> Vec { + s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect() +} + +fn list(s: &mut Session, _: &Value) -> Result { + let heads: Vec = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect(); + Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error})) +} + +fn reload(s: &mut Session, _: &Value) -> Result { + const C: &str = "stroke.arrowheads.reload"; + s.reload_arrowheads(); + if let Some(e) = &s.arrowheads.error { + return Err(bad(C, e.clone())); + } + Ok(json!({"count": s.arrowheads.heads.len(), "skipped": skipped(s)})) +} + +fn edit(s: &mut Session, _: &Value) -> Result { + const C: &str = "stroke.arrowheads.edit"; + let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?; + if let Some(dir) = std::path::Path::new(&path).parent() { + std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; + } + // Made only when no file is there, also when another run makes one at the same moment. + let bytes = vectorcraft_format::save_file(&starter()); + let created = match crate::file_access::unconfined(|| super::fileio::write_new_file(&path, &bytes)) { + Ok(()) => true, + Err(_) if std::path::Path::new(&path).exists() => false, + Err(e) => return Err(bad(C, e.to_string())), + }; + if created { + s.reload_arrowheads(); + } + // Already open: that document comes to the front. + let open = s.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_file(p, &path))); + match open { + Some(i) => { + s.set_active(i); + } + None => { + crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?; + } + } + Ok(json!({"path": path, "created": created})) +} diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 988aa89572..16ab1c76d4 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -170,6 +170,7 @@ pub struct BuiltinBrushLibrary { /// The built-in brush libraries, in menu order: original brushes generated in code. pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, + BuiltinBrushLibrary { id: "ink-and-wash", name: "Ink and Wash", make: ink_and_wash }, BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, @@ -268,6 +269,78 @@ fn art_ribbons() -> BrushLibrary { built("Art Ribbons", doc, brushes) } +/// A band between the edges y = `top(x)` and y = `bottom(x)` for x in `from`..`to`, in `n` steps. +fn between(from: f64, to: f64, n: usize, top: impl Fn(f64) -> f64, bottom: impl Fn(f64) -> f64) -> PathData { + let xs: Vec = (0..=n).map(|i| from + (to - from) * i as f64 / n as f64).collect(); + let mut pts: Vec = xs.iter().map(|&x| Point::new(x, top(x))).collect(); + pts.extend(xs.iter().rev().map(|&x| Point::new(x, bottom(x)))); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// Path `p` filled with gray `hex` at `opacity`: one layer of a wash. +fn wash(p: PathData, hex: &str, opacity: f32) -> Node { + let mut n = filled(p, Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK))); + n.opacity = opacity; + n +} + +/// How much of a wash's width is left at x: rounded ends, full width from 8 pt in. +fn wash_ends(x: f64) -> f64 { + let ends = ((x / 8.0).min(1.0) * ((100.0 - x) / 8.0).min(1.0)).max(0.0); + 0.4 + 0.6 * ends.sqrt() +} + +/// Ink and Wash: Tapered Ink, broad near its start and thinning to a fine tip; Brush Pen, a +/// rounded nib whose size follows the pen pressure; and two washes of translucent grays, Soft Wash +/// with faint edges and Pooling Wash with a darker rim and two light breaks. Black and grays take +/// the stroke color as tints of it. +fn ink_and_wash() -> BrushLibrary { + let tapered = { + let half = |x: f64| 3.0 * (x / 22.0).min(1.0).powf(0.6) * ((100.0 - x) / 78.0).min(1.0).powf(1.4); + black(between(0.0, 100.0, 48, |x| -0.85 * half(x), |x| 1.15 * half(x))) + }; + let pen = Brush { + name: "Brush Pen".into(), + kind: BrushKind::Calligraphic(Calligraphic { + angle: 40.0, + roundness: 75.0, + size: 5.0, + variation: [0.0, 0.0, 2.5], + modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]), + }), + }; + let soft = { + let top = |x: f64| -(4.5 + 0.5 * (x * 0.21).sin() + 0.3 * (x * 0.57 + 1.3).sin()) * wash_ends(x); + let bottom = |x: f64| (4.5 + 0.45 * (x * 0.26 + 2.1).sin() + 0.3 * (x * 0.69 + 0.4).sin()) * wash_ends(x); + group(vec![ + wash(between(0.0, 100.0, 40, top, bottom), "#404040", 0.4), + wash(between(2.0, 98.0, 32, |x| top(x) - 0.7 - 0.3 * (x * 0.43).sin().abs(), |x| top(x) + 0.5), "#404040", 0.2), + wash(between(3.0, 97.0, 32, |x| bottom(x) - 0.5, |x| bottom(x) + 0.6 + 0.35 * (x * 0.37 + 1.0).sin().abs()), "#404040", 0.18), + wash(shapes::ellipse(Rect::new(21.0, -2.4, 39.0, 0.8)), "#e6e6e6", 0.22), + wash(shapes::ellipse(Rect::new(61.0, -0.3, 75.0, 2.3)), "#e6e6e6", 0.18), + ]) + }; + let pooling = { + let top = |x: f64| -(4.0 + 0.4 * (x * 0.18 + 0.7).sin() + 0.2 * (x * 0.53).sin()) * wash_ends(x); + let bottom = |x: f64| (4.0 + 0.35 * (x * 0.23 + 1.9).sin() + 0.25 * (x * 0.61 + 0.2).sin()) * wash_ends(x); + let rim = |x: f64, k: f64, phase: f64| 0.35 + k * (0.5 + 0.5 * (x * 0.29 + phase).sin()); + group(vec![ + wash(between(0.0, 100.0, 40, top, bottom), "#4d4d4d", 0.35), + wash(between(1.0, 99.0, 40, top, |x| top(x) + rim(x, 0.45, 0.0)), "#262626", 0.55), + wash(between(1.0, 99.0, 40, |x| bottom(x) - rim(x, 0.5, 1.1), bottom), "#262626", 0.55), + wash(polygon(&[(34.0, -2.6), (39.0, -2.8), (36.0, 2.7), (31.0, 2.5)]), "#e6e6e6", 0.2), + wash(polygon(&[(70.0, -2.4), (74.0, -2.5), (72.0, 2.6), (68.0, 2.4)]), "#e6e6e6", 0.18), + ]) + }; + let brushes = vec![ + art("Tapered Ink", tapered, Colorization::Tints), + pen, + art("Soft Wash", soft, Colorization::Tints), + art("Pooling Wash", pooling, Colorization::Tints), + ]; + built("Ink and Wash", Document::new(100.0, 100.0), brushes) +} + fn nib(name: &str, angle: f64, roundness: f64, size: f64) -> Brush { Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) } } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 53e1b655a1..94fe55c119 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 9463dd8cf1..e98dbd5619 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -491,7 +491,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result { if text.trim().is_empty() { return Err(bad(C, "the text is empty")); } - let style = super::super::create::new_type_style(s, &Value::Null); + let style = super::super::create::new_type_style(s, &Value::Null, C)?; let system_layout = s.doc()?.doc.setup.system_layout; let layout = |t: &mut TextObject| { t.system_layout = system_layout; diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index da7dc42ece..1046c4a948 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -210,8 +210,12 @@ fn load_profile(s: &mut Session, p: &Value) -> Result { let remembered = s.store_profile(&info.name, &bytes); // A new file of a working space: the active settings still hold the old one. let working = cms::active_settings(); - if working.rgb == info.name || working.cmyk == info.name { + let is_working = working.rgb == info.name || working.cmyk == info.name; + if is_working { cms::set_active(&working).map_err(|e| bad(C, e.to_string()))?; + } + // A new file of a working space, or of the CMYK profile View → Proof Setup targets: redraw. + if is_working || proof_targets(&info.name) { touch_all(s); } Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered})) @@ -233,10 +237,17 @@ fn remove_profile(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("no profile named `{name}`"))); } s.unstore_profile(&info.name); - touch_all(s); + if proof_targets(&info.name) { + touch_all(s); + } Ok(json!({"removed": info.name})) } +/// Whether View → Proof Setup targets the CMYK profile `name`. +fn proof_targets(name: &str) -> bool { + matches!(proof::view().setup.target, ProofTarget::Cmyk(t) if t == name) +} + #[cfg(target_arch = "wasm32")] fn load_profile(_: &mut Session, _: &Value) -> Result { Err(bad("color.loadProfile", "loading profiles from disk isn't available in the browser")) @@ -274,14 +285,17 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result { Ok(()) })?; let mut st = cms::active_settings(); + let before = st.clone(); if let Some(r) = &rgb { st.rgb = r.clone(); } if let Some(c) = &cmyk { st.cmyk = c.clone(); } - cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; - touch_all(s); + if st != before { + cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; + touch_all(s); + } } Ok(json!({"rgb": rgb, "cmyk": cmyk})) } @@ -760,17 +774,33 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result { if let Some(on) = on_param(p, "proof", C)? { v.proof_colors = on; } - proof::set_view(v.clone()); - touch_all(s); + store_view(s, v.clone()); Ok(view_json(&v)) } +/// Make `v` the proof view and redraw every document. When `v` equals the current view, the view +/// and the revisions stay as they are; Separations Preview's visible plates are compared as a set. +fn store_view(s: &mut Session, v: proof::ProofView) { + let shown = |v: &proof::ProofView| proof::ProofView { + separations: v.separations.as_ref().map(|vis| { + let mut vis = vis.clone(); + vis.sort(); + vis.dedup(); + vis + }), + ..v.clone() + }; + if shown(&v) != shown(&proof::view()) { + proof::set_view(v); + touch_all(s); + } +} + fn toggle_view(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut proof::ProofView, Option)) -> Result { let on = on_param(p, "on", cmd)?; let mut v = proof::view(); f(&mut v, on); - proof::set_view(v.clone()); - touch_all(s); + store_view(s, v.clone()); Ok(view_json(&v)) } @@ -802,7 +832,15 @@ fn separations(s: &mut Session, p: &Value) -> Result { match p.get("plates") { None | Some(Value::Null) => {} Some(Value::Array(a)) if a.iter().all(Value::is_string) => { - v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::>>()?); + let mut vis: Vec = Vec::new(); + for n in a.iter().filter_map(Value::as_str) { + let n = check(n)?; + // A plate named twice is shown once. + if !vis.contains(&n) { + vis.push(n); + } + } + v.separations = Some(vis); } Some(_) => { let key = "plates"; @@ -821,8 +859,7 @@ fn separations(s: &mut Session, p: &Value) -> Result { vis.push(n); } } - proof::set_view(v); - touch_all(s); + store_view(s, v); Ok(plates_json(s)) } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 37473cc59f..ebef729fd3 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -109,7 +109,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt 0.1..1296 (outside it: an error, and no type is made), font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -739,7 +739,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { check_numbers(a, &["width", "height"], "text.create")?; } let text = str_param(p, "text").unwrap_or(""); - let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p)); + let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p, "text.create")?); // Turned about the point (the first baseline's start, an area's top-left corner): type set // along a Constrain Angle. let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", "text.create")? } else { 0.0 }; @@ -848,11 +848,11 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str, t: &mut TextObject) /// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB /// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults, /// filled with the current fill unless that is None or white (black then), in the document's -/// language. -pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { +/// language. A `size` outside [`super::typecmd::TYPE_SIZES`] is an error. +pub(crate) fn new_type_style(s: &Session, p: &Value, cmd: &str) -> Result { let mut style = CharStyle::default(); if let Some(sz) = p.get("size").and_then(Value::as_f64) { - style.size = sz.clamp(0.1, 1296.0); + style.size = super::typecmd::given_type_size(sz, cmd)?; } if let Some(f) = str_param(p, "font") { style.font_family = f.to_string(); @@ -873,5 +873,5 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { let chosen = |name: &str| vectorcraft_doc::setup::char_language(name).ok().flatten(); let document = s.doc().ok().and_then(|d| chosen(&d.doc.setup.language)); style.language = document.or_else(|| chosen(&s.prefs.hyphenation_language)).map(str::to_string); - style + Ok(style) } diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index ed83a01410..dd6af8e6eb 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; -use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, named_ids, targets}; pub fn specs() -> Vec { vec![ @@ -21,13 +21,13 @@ pub fn specs() -> Vec { has_doc, selection ), - cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document", has_doc, generate), + cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document; ids and id are not used, and a malformed one is an error", has_doc, generate), cmd!( "css.export", "Export CSS", [], None, - "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", + "{path?: string, scope?: selection|all (default selection when left out; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} a path or scope given as anything but a string, null included, is an error, and so is a malformed ids or id with scope all, which does not use them; write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", has_doc, export ), @@ -77,16 +77,34 @@ fn selection(s: &mut Session, p: &Value) -> Result { Ok(result(&sheet(s, p, ids, "css.selection")?)) } +/// Checks the form of the `ids` or `id` that `p` gives a command that exports every object and does +/// not use them: a malformed one is an error, as it is for the selection. +fn unused_ids(p: &Value) -> Result<()> { + named_ids(p).map(|_| ()) +} + fn generate(s: &mut Session, p: &Value) -> Result { + unused_ids(p)?; let ids = everything(s)?; Ok(result(&sheet(s, p, ids, "css.generate")?)) } +/// The string `p` gives for `key`, or `None` when `p` leaves it out. Any value given that is not a +/// string, null included, is an error: "`{key}` must be a string". +fn given_text<'a>(p: &'a Value, key: &str, cmd: &str) -> Result> { + p.get(key).map(|v| v.as_str().ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose() +} + fn export(s: &mut Session, p: &Value) -> Result { const C: &str = "css.export"; - let ids = match str_param(p, "scope").unwrap_or("selection") { + let scope = given_text(p, "scope", C)?; + let path = given_text(p, "path", C)?; + let ids = match scope.unwrap_or("selection") { "selection" => targets(s, p)?, - "all" => everything(s)?, + "all" => { + unused_ids(p)?; + everything(s)? + } other => return Err(bad(C, format!("scope must be selection or all (got {other})"))), }; let sheet = sheet(s, p, ids, C)?; @@ -115,7 +133,7 @@ fn export(s: &mut Session, p: &Value) -> Result { } } let n = sheet.rules.len(); - match str_param(p, "path") { + match path { Some(path) => { write_file(path, css.as_bytes())?; let folder = std::path::Path::new(path).parent(); diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index e1b88d5d9f..44ac08d7ad 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -568,7 +568,7 @@ fn appearance(o: &DimensionOptions, mode: ColorMode, ext: bool, arrows: bool) -> ArrowStyle::Tick => Some(Arrowhead::Slash), ArrowStyle::None => None, }; - st.start_arrow = head; + st.start_arrow = head.clone(); st.end_arrow = head; st.arrow_scale = (o.arrow_scale, o.arrow_scale); st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 6fe6aada9e..85f9999004 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -38,7 +38,7 @@ pub fn specs() -> Vec { has_doc, doc_move ), - cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), + cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → path (the document's file; null when automation read roots are in force and the file is outside them), layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find), cmd!( @@ -50,7 +50,11 @@ pub fn specs() -> Vec { has_doc, doc_compare ), - cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), + cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so is a data set's image value outside them; a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| { + let st = s.doc()?; + let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st)); + Ok(serde_json::to_value(&*d).unwrap_or(Value::Null)) + }), cmd!( "document.setUnits", "Units", diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 5737235963..4e7e1fa658 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -546,12 +546,14 @@ fn expand_piece_leaves( } /// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its -/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no +/// path (a plain path then), and every geometry effect of its own removed, with each raster effect +/// following the Transform effects after it +/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)). `None` when it has no /// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their /// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a /// path that one paints goes through [`expand_pieces`] instead. pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, without_geometry}; if !has_geometry(&l.appearance.effects) { return None; } @@ -562,7 +564,7 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - m.appearance.effects.retain(|e| !is_geometry(&e.id)); + m.appearance.effects = without_geometry(&m.appearance.effects); Some(m) } @@ -604,9 +606,10 @@ fn with_path(l: &Node, path: PathData) -> Node { } /// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which -/// shape the outline it paints ([`fill_shape`]). +/// shape the outline it paints ([`fill_shape`]), with each raster effect following the Transform +/// effects after it ([`without_geometry`](vectorcraft_render::effects::without_geometry)). fn fill_effects(f: &FillLayer) -> Vec { - f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect() + vectorcraft_render::effects::without_geometry(&f.effects) } /// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]). diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 61eac58f37..0c20633608 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -71,7 +71,6 @@ pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub(crate) use export::with_artboard_fills; pub(crate) use export::{export_crop, isolated}; pub use export::{export_doc, export_source}; -pub(crate) use load::check_not_lossy_overwrite; use load::err; pub(crate) use load::import_svg; pub(crate) use load::is_placeholder; @@ -82,6 +81,7 @@ pub use load::{ Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image, read_for_open, texture_image, }; +pub(crate) use load::{check_files_not_lossy_overwrite, check_not_lossy_overwrite, lossy_source}; pub use load::{losses_summary, overwrite_losses}; pub use mesh3d::{DEFAULT_FORMAT_3D, FORMATS_3D, format_3d_label}; pub(crate) use mesh3d::{mesh_file_names, mesh_files}; @@ -130,7 +130,8 @@ mod tests_readonly; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; pub(crate) use access::{ - absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file, + absolute_path, create_dir, document_path, file_created, file_stamp, naming_in_error, read_file, read_file_head, shown_link, shown_path, + write_file, write_new_file, }; #[cfg(all(test, windows))] pub(crate) use access::{document_path_on, document_path_with}; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index c3668645e1..6488972d31 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -65,6 +65,62 @@ pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::bo std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name }) } +/// What an error names a file or folder outside the read roots by when [`shown_path`] gives no +/// name for it, or gives one that is not valid text. +pub(crate) const UNNAMED: &str = "a file or folder outside the read roots"; + +/// An error about `path`, a file or folder the client did not give, as the request being served +/// may see it: where the message names `path` ([`naming_in_message`]), it names it as +/// [`shown_path`] does, or as [`UNNAMED`] when that is empty or holds a replacement character. The +/// roots the message names and the reason stay whole. Unchanged when `path` is [`shown`]. +pub(crate) fn naming_in_error(e: EngineError, path: &str) -> EngineError { + if path.is_empty() || shown(path) { + return e; + } + naming_as(e, path, &error_name(path)) +} + +/// What an error names `path` by when it is outside the read roots: [`shown_path`], or +/// [`UNNAMED`] when that is empty or holds a replacement character. +pub(crate) fn error_name(path: &str) -> String { + let name = shown_path(path); + if name.is_empty() || name.contains('\u{FFFD}') { UNNAMED.to_string() } else { name.into_owned() } +} + +/// [`naming_in_error`] with `path` named `name`, whether or not it is [`shown`] (a name worked out +/// earlier, for the request that prepared the work). +pub(crate) fn naming_as(e: EngineError, path: &str, name: &str) -> EngineError { + match e { + EngineError::Other(msg) => EngineError::Other(naming_in_message(&msg, path, name)), + EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: naming_in_message(&msg, path, name) }, + EngineError::ReadOnlyLocation(msg) => EngineError::ReadOnlyLocation(naming_in_message(&msg, path, name)), + e => e, + } +} + +/// `msg` with `path` given as `name` where it begins a path the message is about: at the start of +/// `msg` (`: `), after a `: ` (`... outside the write root : `) or after +/// `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message names +/// stays whole, even one inside `path`. +pub(crate) fn naming_in_message(msg: &str, path: &str, name: &str) -> String { + const BEFORE: [&str; 2] = [": ", "cannot resolve "]; + let mut out = String::with_capacity(msg.len()); + let mut rest = msg; + loop { + if let Some(after) = rest.strip_prefix(path) + && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) + { + out.push_str(name); + rest = after; + } + let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; + out.push_str(&rest[..i + len]); + rest = &rest[i + len..]; + } + out.push_str(rest); + out +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { @@ -466,3 +522,52 @@ pub(crate) fn create_dir(path: &str) -> Result<()> { pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> { Err(no_fs(path)) } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use crate::file_access::{AutomationRoots, confine}; + + /// An error about a path the client did not give, under roots that don't let it be read: the + /// path is named by its file name (after a `/` or a `\`), or as [`UNNAMED`] when it ends in no + /// name or its name is not valid text, and every root and the reason stay whole. A path inside the + /// read roots, and any path without roots in force, stay whole. + #[test] + fn an_error_names_a_path_outside_the_read_roots_by_its_name() { + let base = std::env::temp_dir().join(format!("vc-access-naming-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (a, b, out) = (base.join("a"), base.join("b"), base.join("kept-out")); + for f in [&a, &b, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let text = |p: &std::path::Path| p.to_string_lossy().into_owned(); + let roots = AutomationRoots::new(&[a.as_path(), b.as_path()], &[a.as_path(), b.as_path()]).unwrap(); + let file = text(&out.join("doc.svg")); + let raw = confine(roots.as_ref(), || crate::file_access::check_write(&file)).unwrap_err(); + let shown_root = |p: &std::path::Path| crate::cmd::findfiles::plain(std::fs::canonicalize(p).unwrap()).display().to_string(); + let (ra, rb) = (shown_root(&a), shown_root(&b)); + let named = |e: EngineError, path: &str| confine(roots.as_ref(), || naming_in_error(e, path)).to_string(); + + assert_eq!(named(EngineError::Other(raw.clone()), &file), format!("automation path rejected: outside the write roots {ra}, {rb}: doc.svg")); + let io = named(EngineError::Other(format!("{file}: disk full")), &file); + assert_eq!(io, "doc.svg: disk full"); + let bad = named(EngineError::BadParams { cmd: "x".into(), msg: format!("{file}: not a file") }, &file); + assert_eq!(bad, "invalid parameters for `x`: doc.svg: not a file"); + // A save refused by the file system keeps its type and its code. + let read_only = confine(roots.as_ref(), || naming_in_error(EngineError::ReadOnlyLocation(format!("{file}: Permission denied")), &file)); + assert!(matches!(&read_only, EngineError::ReadOnlyLocation(_))); + assert_eq!(read_only.to_string(), format!("{}: doc.svg: Permission denied", crate::READ_ONLY_LOCATION)); + // A Windows path written with `\`, a path that ends in no name and a name that isn't text. + let win = r"C:\Users\me\secret\doc.svg"; + assert_eq!(named(EngineError::Other(format!("{win}: denied")), win), "doc.svg: denied"); + let folder = format!("{}/", text(&out)); + assert_eq!(named(EngineError::Other(format!("{folder}: denied")), &folder), format!("{UNNAMED}: denied")); + let lossy = text(&out.join("bad\u{FFFD}.svg")); + assert_eq!(named(EngineError::Other(format!("{lossy}: denied")), &lossy), format!("{UNNAMED}: denied")); + // Readable, or nothing confined: whole. + let inside = text(&a.join("x.svg")); + assert_eq!(named(EngineError::Other(format!("{inside}: denied")), &inside), format!("{inside}: denied")); + assert_eq!(naming_in_error(EngineError::Other(raw.clone()), &file).to_string(), raw); + let _ = std::fs::remove_dir_all(base); + } +} diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 4953785189..232d5ba1f4 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -1429,6 +1429,42 @@ mod tests { assert_eq!(written.len(), 2, "{written:?}"); } + /// A Data Sets batch whose Save step has no path saves the document's own file. When that file is + /// outside the client's read and write roots, each row's error keeps the write root and names the + /// file by its file name, with Stop on Error on and off. A source folder the client gives stays + /// whole in the error. + #[test] + fn dataset_batch_step_error_hides_document_path() { + use crate::file_access::{AutomationRoots, confine}; + + let base = tmp("roots-error"); + let (inside, outside) = (base.join("in"), base.join("kept-out")); + for f in [&inside, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let mut s = with_data_sets(&["Alpha", "Beta"]); + let own = outside.join("doc.vectorcraft").to_string_lossy().into_owned(); + s.execute("document.save", &json!({"path": own})).unwrap(); + let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string(); + let hidden = outside.to_string_lossy().into_owned(); + for (stop, rows) in [(true, 1), (false, 2)] { + let p = json!({"source": "datasets", "stopOnError": stop, "commands": [{"command": "document.save", "params": {}}]}); + let r = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap(); + assert_eq!((r["failed"].as_u64(), r["processed"].as_array().map(Vec::len)), (Some(rows), Some(rows as usize)), "{r}"); + for row in r["processed"].as_array().unwrap() { + let e = row["error"].as_str().unwrap_or_default(); + let want = format!("batch step 0 (`document.save`) failed: automation path rejected: outside the write root {root}: doc.vectorcraft"); + assert_eq!(e, want, "{r}"); + } + assert!(!r.to_string().contains(&hidden), "{r}"); + } + let p = json!({"source": "folder", "folder": hidden, "commands": [{"command": "object.unlockAll", "params": {}}]}); + let e = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap_err().to_string(); + assert!(e.contains(&hidden), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + /// The names of the files in `dir`, sorted. fn file_names(dir: &std::path::Path) -> Vec { let mut names: Vec = diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 7f95b8e203..fe7c9d8f3e 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ec1d7e36ba..6f0f8fd533 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -398,7 +398,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); // Relative links are found in the SVG's folder (when `name` is a path). let folder = std::path::Path::new(name).parent().map(|f| f.to_string_lossy()).filter(|f| !f.is_empty()).map(|f| absolute_path(&f)); - restore_or_import(editing, || import_svg(&text, folder.as_deref()))? + restore_or_import(editing, || open_svg(&text, folder.as_deref()))? } "pdf" | "ai" | "ait" => { // The file is read and checked once, for the document it carries, its pages, the @@ -573,6 +573,33 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result /// own): rasters stay linked, SVG files become art, missing ones placeholders (see /// [`vectorcraft_svg::ImportOptions`]). pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + import_svg_finding(text, folder, &std::collections::HashSet::new()).map(|(doc, warnings, _)| (doc, warnings)) +} + +/// [`import_svg`] for a document opened from an SVG: with the variable data the file carries (SVG +/// Options › Include Variable Data), read as [`crate::cmd::variablecmds::svg_library`] says. Variable +/// data that can't be read is left out with a warning; the art opens either way. +fn open_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + let lib = match crate::cmd::variablecmds::svg_library(text) { + None => return import_svg(text, folder), + Some(Err(e)) => { + let (doc, mut warnings) = import_svg(text, folder)?; + warnings.push(format!("the file's variable data wasn't read: {e}")); + return Ok((doc, warnings)); + } + Some(Ok(lib)) => lib, + }; + let (mut doc, mut warnings, found) = import_svg_finding(text, folder, &lib.elements())?; + warnings.extend(crate::cmd::variablecmds::give_svg_library(&mut doc, lib, &found)); + Ok((doc, warnings)) +} + +/// [`import_svg`], also returning the object made of each element whose id `ids` lists. +fn import_svg_finding( + text: &str, + folder: Option<&str>, + ids: &std::collections::HashSet, +) -> Result<(Document, Vec, vectorcraft_svg::Found)> { // The importer reads on a thread of its own (for its stack): what may be read is what may be // read here. let scope = crate::file_access::ScopeHandle::capture(); @@ -588,7 +615,7 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Some((bytes, link)) }) }; - vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err) + vectorcraft_svg::import_finding(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }, ids).map_err(err) } /// Open a file's bytes as the new active document (what `document.open` does) → @@ -1019,13 +1046,38 @@ fn is_loss(note: &str) -> bool { /// Another name keeps the original file, with all it has. pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> { let Some(losses) = overwrite_losses(st, target, p) else { return Ok(()) }; + Err(lossy_overwrite(target, losses, cmd)) +} + +/// The refusal to write `target`, the file the document was read from, when reading it left out `losses`. +fn lossy_overwrite(target: &str, losses: &[String], cmd: &str) -> EngineError { let what = losses_summary(losses); - Err(bad( + bad( cmd, format!( "{target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway" ), - )) + ) +} + +/// The file the document was read from and what reading it left out, when a write to that file +/// needs `acknowledgeLoss` and `p` does not have it ([`overwrite_losses`]). +pub(crate) fn lossy_source(st: &crate::DocState, p: &Value) -> Option<(String, Vec)> { + let source = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty())?; + (!bool_or(p, "acknowledgeLoss", false)).then(|| (source.to_string(), st.import_losses.clone())) +} + +/// None of the files `targets` (every file a save writes, before any is written) is the +/// `source` file that reading left `losses` out of ([`lossy_source`]). +pub(crate) fn check_files_not_lossy_overwrite<'a>( + source: &(String, Vec), + targets: impl IntoIterator, + cmd: &str, +) -> Result<()> { + match targets.into_iter().find(|t| same_file(&source.0, t)) { + Some(target) => Err(lossy_overwrite(target, &source.1, cmd)), + None => Ok(()), + } } /// What writing the file `target` for document `st` would lose for good: the notes of what reading diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 58677cd5d4..6d2b0b35d5 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -99,6 +99,10 @@ pub struct SavePlan { /// `preview`, `precision`) given beside the path, and each key of `options`, that the format /// doesn't read, naming the file the save writes. pub notes: Vec, + /// The file the document was read from and what reading it left out, when the save needs + /// `acknowledgeLoss` to write over it and was not given it: every file the save writes is + /// checked against it before the first is written. + pub lossy_source: Option<(String, Vec)>, } impl SavePlan { @@ -303,7 +307,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { }; let modified = date_param(p, "modified", cmd)?; if let Some(path) = &path { - super::check_not_lossy_overwrite(st, path, p, cmd)?; + // Without a path, it is the document's own file: the error names it as the request may see it. + let given = str_param(p, "path").is_some_and(|g| !g.is_empty()); + super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?; } let written = written_as(cmd, format, &Value::Object(options.clone())); let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string()); @@ -313,7 +319,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { .chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}"))) .map(|k| format!("`{k}` was not used: {} files don't read it", written.label)) .collect(); - Ok(SavePlan { mode, path, format, options, name, folder, modified, notes }) + let lossy_source = super::lossy_source(st, p); + Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source }) } /// The native save options, which a save also takes beside the path. @@ -910,6 +917,9 @@ impl SaveJob { return Ok(out); }; let files = enc.named(doc, path); + if let Some(source) = &plan.lossy_source { + super::check_files_not_lossy_overwrite(source, files.iter().map(|(p, _)| p.as_str()), plan.mode.command())?; + } if let Some(stopped) = stop(&files.iter().map(|(p, _)| p.as_str()).collect::>()) { return Ok(stopped); } @@ -1021,6 +1031,8 @@ impl SaveJob { } // The other open documents that place this one show the new version. let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template")); + // A save of the arrowheads file lists its heads in the arrowhead menus. + let native = self.plan.format.id == "vectorcraft"; if let Some(st) = s.document_mut(uid) { self.finish(st); } @@ -1028,6 +1040,9 @@ impl SaveJob { crate::cmd::recovery::forget(s, uid); } if let Some(path) = written { + if native { + s.arrowheads_saved(&path); + } crate::cmd::links::refresh_placed(s, uid, &path); } } @@ -1048,7 +1063,12 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result let plan = save_plan(s, mode, p)?; let job = save_job(s, plan)?; let uid = s.doc()?.uid; - let mut out = job.write_streamed(&mut super::write_save_file_with)?; + let given = str_param(p, "path").is_some_and(|g| !g.is_empty()); + // Without a path, the files written are the document's own file and those beside it. + let mut write = |path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { + super::write_save_file_with(path, fill).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) }) + }; + let mut out = job.write_streamed(&mut write)?; job.complete(s, uid); leave_out_unreadable_save_paths(&mut out, str_param(p, "path")); Ok(out) @@ -1103,7 +1123,8 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> { fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); + let bytes = read_file(&path).map_err(|e| super::access::naming_in_error(e, &path))?; + let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &bytes)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index ca28407356..913f4a55a2 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -649,14 +649,14 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { let p = with_preset(s, p)?; let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) }; let mut r = export(s, &p).map_err(|e| match hidden { - Some(folder) => leave_out_folder_from_error(e, folder), + Some(folder) => super::access::naming_in_error(e, folder), None => e, })?; remember(s, &p); // A prepared export is written later, maybe on another thread: it names the folder as this // request may see it. if let (Some(folder), Some(Some(export))) = (hidden, s.screens_hand_off.as_mut()) { - export.hidden = Some((folder.to_string(), super::access::shown_path(folder).into_owned())); + export.hidden = Some((folder.to_string(), super::access::error_name(folder))); } if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { leave_out_unreadable_folder(&mut r, folder); @@ -664,45 +664,6 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { Ok(r) } -/// An error of an export into `folder`, a folder outside the read roots that the client did not -/// give: `folder` as the path the error is about names it by its own name -/// ([`super::access::shown_path`]), and the rest of the message stays. -fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError { - leave_out_folder_named(e, folder, &super::access::shown_path(folder)) -} - -/// [`leave_out_folder_from_error`] with `folder` named `name`. -fn leave_out_folder_named(e: EngineError, folder: &str, name: &str) -> EngineError { - match e { - EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, name)), - EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, name) }, - e => e, - } -} - -/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start -/// of `msg` (`: `), after a `: ` (`... outside the write root : `) or -/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message -/// names stays whole, even one inside `folder`. -fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String { - const BEFORE: [&str; 2] = [": ", "cannot resolve "]; - let mut out = String::with_capacity(msg.len()); - let mut rest = msg; - loop { - if let Some(after) = rest.strip_prefix(folder) - && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) - { - out.push_str(name); - rest = after; - } - let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; - out.push_str(&rest[..i + len]); - rest = &rest[i + len..]; - } - out.push_str(rest); - out -} - /// The reply `r` of an export into `folder`, a folder the client did not give, as the request being /// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's /// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), @@ -1162,7 +1123,7 @@ impl ScreenExport { let r = encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }); match hidden { Some((folder, name)) => { - let mut r = r.map_err(|e| leave_out_folder_named(e, &folder, &name))?; + let mut r = r.map_err(|e| super::access::naming_as(e, &folder, &name))?; leave_out_folder(&mut r, &folder); Ok(r) } @@ -1516,7 +1477,8 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result { #[cfg(test)] mod tests { - use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name}; + use super::super::access::naming_in_message; + use super::{Output, ScreenSize, safe_file_name}; /// A hidden export folder is named by its name where a message is about it, and a root that /// starts with the same text, or a longer path, stays whole. @@ -1533,7 +1495,7 @@ mod tests { ("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"), ]; for (msg, want) in cases { - assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}"); + assert_eq!(naming_in_message(msg, f, "out"), want, "{msg}"); } } diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 3123fdcfb1..eebac553d3 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -90,6 +90,12 @@ pub const OPTIONS: &[FormatOption] = &[ default: "false", description: "embed the fonts type uses as @font-face, subset to the characters used (whole when the font's licence forbids subsetting, left out with a warning when it forbids embedding)", }, + FormatOption { + name: "variableData", + ty: "boolean", + default: "false", + description: "Include Variable Data: write the text variables, the data sets and the type object each text variable is bound to as a variable library (variableSets) that document.open and variable.loadLibrary read back; variables of other kinds, and what is past the limits variable.loadLibrary reads (256 text variables, 1,000 data sets, 8 MiB), are left out with a warning", + }, FormatOption { name: "svg", ty: "object", diff --git a/crates/engine/src/cmd/findfiles/tests.rs b/crates/engine/src/cmd/findfiles/tests.rs index bf51ebdac7..9340e5a8c6 100644 --- a/crates/engine/src/cmd/findfiles/tests.rs +++ b/crates/engine/src/cmd/findfiles/tests.rs @@ -43,12 +43,62 @@ impl Visitor for Blocked { } } +/// A test's folder tree, removed with everything in it when dropped, also when the test panics, +/// and also when the test locked a folder in it. A removal that fails (a walker still holding a +/// file open) is ignored. +struct Tree(PathBuf); + +impl std::ops::Deref for Tree { + type Target = PathBuf; + fn deref(&self) -> &PathBuf { + &self.0 + } +} + +impl Drop for Tree { + fn drop(&mut self) { + if std::fs::remove_dir_all(&self.0).is_err() { + unlock(&self.0); + let _ = std::fs::remove_dir_all(&self.0); + } + } +} + +/// Gives the owner read, write and search permission on `folder` and on every folder in it, so +/// that a tree holding a folder a test locked can be removed. Links are not followed. +#[cfg(unix)] +fn unlock(folder: &Path) { + use std::os::unix::fs::PermissionsExt; + let Ok(meta) = std::fs::symlink_metadata(folder) else { return }; + if !meta.is_dir() { + return; + } + let mode = meta.permissions().mode() & 0o7777; + if mode & 0o700 != 0o700 { + let _ = std::fs::set_permissions(folder, std::fs::Permissions::from_mode(mode | 0o700)); + } + let Ok(entries) = std::fs::read_dir(folder) else { return }; + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|t| t.is_dir()) { + unlock(&entry.path()); + } + } +} + +/// Folders on other systems are removed as they are. +#[cfg(not(unix))] +fn unlock(_: &Path) {} + /// A fresh folder (canonical, as a search reports it) holding `paths`: files, and folders where -/// they end in `/`. -fn tree(tag: &str, paths: &[&str]) -> PathBuf { - let root = vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}")); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); +/// they end in `/`. The folder is removed when the returned [`Tree`] is dropped. +fn tree(tag: &str, paths: &[&str]) -> Tree { + let mut root = Tree(vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}"))); + // Empties what a tree whose removal failed left, keeping the folder as `temp_dir` made it + // (owner-only on Unix). + for entry in std::fs::read_dir(&root.0).unwrap().flatten() { + let path = entry.path(); + let _ = if entry.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) }; + } for p in paths { let path = root.join(p); if p.ends_with('/') { @@ -58,7 +108,54 @@ fn tree(tag: &str, paths: &[&str]) -> PathBuf { std::fs::write(&path, b"font").unwrap(); } } - plain(std::fs::canonicalize(&root).unwrap()) + root.0 = plain(std::fs::canonicalize(&root.0).unwrap()); + root +} + +#[test] +fn a_tree_is_removed_when_its_test_ends() { + let path = { + let root = tree("removed", &["a/b/one.ttf", "c/"]); + assert!(root.join("a/b/one.ttf").is_file() && root.join("c").is_dir()); + root.to_path_buf() + }; + assert!(!path.exists(), "{} is left", path.display()); +} + +#[test] +fn a_tree_is_removed_when_its_test_panics() { + let mut path = PathBuf::new(); + let r = vectorcraft_testkit::catch_quiet::<()>(|| { + let root = tree("unwound", &["a/b/one.ttf"]); + path = root.to_path_buf(); + panic!("a failed assertion"); + }); + assert_eq!(r.err().as_deref(), Some("a failed assertion")); + assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display()); +} + +#[cfg(unix)] +#[test] +fn a_tree_is_private_to_this_user() { + use std::os::unix::fs::PermissionsExt; + let root = tree("private", &["a/one.ttf"]); + let mode = std::fs::metadata(&*root).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "{}: mode {mode:o}", root.display()); +} + +#[cfg(unix)] +#[test] +fn a_tree_with_a_locked_folder_is_removed_when_its_test_panics() { + use std::os::unix::fs::PermissionsExt; + let mut path = PathBuf::new(); + let r = vectorcraft_testkit::catch_quiet::<()>(|| { + let root = tree("locked", &["a/locked/b/one.ttf"]); + path = root.to_path_buf(); + std::fs::set_permissions(root.join("a/locked"), std::fs::Permissions::from_mode(0o000)).unwrap(); + panic!("a failed assertion"); + }); + assert_eq!(r.err().as_deref(), Some("a failed assertion")); + assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display()); } /// The rules of a search of `root`: its `Users/me` is the home folder, `refused` an app's folder. diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 43d9a8e05f..f93cdca7b9 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -850,14 +850,23 @@ fn move_node(s: &mut Session, p: &Value) -> Result { ok() } -fn rect_from(p: &Value, default: Rect) -> Rect { +/// The rectangle from `x`, `y`, `width` and `height`, each defaulting to `default`'s. Sides are kept as given. +fn rect_unclamped(p: &Value, default: Rect) -> Rect { let x = f64_or(p, "x", default.x0); let y = f64_or(p, "y", default.y0); - let w = f64_or(p, "width", default.width()).max(1.0); - let h = f64_or(p, "height", default.height()).max(1.0); + let w = f64_or(p, "width", default.width()); + let h = f64_or(p, "height", default.height()); Rect::new(x, y, x + w, y + h) } +/// [`rect_unclamped`] with each supplied side raised to at least 1 pt, as `artboard.setProps` uses it. +/// A side left out keeps `default`'s, even below 1 pt. +fn rect_from(p: &Value, default: Rect) -> Rect { + let r = rect_unclamped(p, default); + let floor = |key: &str, side: f64| if p.get(key).and_then(Value::as_f64).is_some() { side.max(1.0) } else { side }; + Rect::new(r.x0, r.y0, r.x0 + floor("width", r.width()), r.y0 + floor("height", r.height())) +} + /// An artboard command's `color` param, its background color: None when absent, `Some(None)` for /// null (no color), else `Some(Some(color))`. fn artboard_color(p: &Value, cmd: &str) -> Result>> { @@ -942,7 +951,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { src + side.offset(src) } None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")), - None => rect_from(p, def), + None => rect_unclamped(p, def), }; // A size taken from another artboard (`side`, or width or height left out) keeps to the limit too. if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) { diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index e0bacc3859..162bb679f6 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -28,7 +28,9 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; -use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions}; +use vectorcraft_doc::{ + Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, +}; use vectorcraft_geom::{Affine, PathData, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; @@ -68,7 +70,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, 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)}", + "{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; to is the file name alone when the document's folder is outside the automation read roots)}", has_doc, relink ), @@ -1195,6 +1197,78 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { }); } +/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without +/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not +/// allow names its file alone, without its relative path or the size and date kept of its file, +/// and each data set's image value it does not allow names its file alone too. The folder Export +/// for Screens last wrote to is left out of the export settings when it is outside the read roots. +/// A link whose path is allowed but whose relative path leads outside the read roots loses its +/// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited +/// as it was before, opacity masks and the art of the stored brushes are all checked. A stored +/// brush entry that does not read as a brush is left out of the copy, since its links cannot be +/// checked, and a stored brush library that is not a list becomes an empty one. +pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Cow<'a, Document> { + if crate::file_access::current().is_none() { + return Cow::Borrowed(d); + } + let doc_path = base.doc; + // A stored relative path is followed from the folder of the file the document was read from, + // and a link is also looked for from the document's own folder: it is shown only when both + // places it names are inside the read roots. + let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from)); + let mut d = d.clone(); + // The pattern being edited as it was before is checked as one more pattern, then put back. + let original = d.pattern_edit.as_mut().and_then(|e| e.original.take()); + let edited = original.is_some(); + d.patterns.extend(original); + // The art of each stored brush is checked as more layers, then put back. + let stored = d.unknown.get(vectorcraft_brush::DOC_KEY).map(|v| v.as_array().cloned().unwrap_or_default()); + let mut brushes: Vec = + stored.iter().flatten().filter_map(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).ok()).collect(); + let layers = d.layers.len(); + d.layers.extend(brushes.iter().flat_map(|b| b.art()).map(|n| Arc::new(n.clone()))); + d.update_links_where( + |l| !path_shown(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden), + |l| { + if path_shown(&l.path, base.doc) { + l.relative = None; + } else { + let name = l.name().to_string(); + *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + } + }, + ); + let mut checked = d.layers.split_off(layers).into_iter(); + if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) { + e.original = Some(def); + } + if stored.is_some() { + // Each brush is written as it reads, so fields it does not read are left out too. + let mut kept = vec![]; + for b in &mut brushes { + for (slot, art) in b.art_mut().into_iter().zip(&mut checked) { + *slot = Arc::unwrap_or_clone(art); + } + kept.extend(serde_json::to_value(&*b).ok()); + } + d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(kept)); + } + if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) { + d.export_settings.remove("folder"); + } + let hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if !path_shown(path, doc_path)); + if d.variables.datasets.iter().any(|set| set.values.values().any(hidden)) { + for v in d.variables.datasets.iter_mut().flat_map(|set| set.values.values_mut()) { + if hidden(&*v) + && let DataValue::Image(path) = v + { + *path = LinkInfo::new(path.as_str()).name().to_string(); + } + } + } + Cow::Owned(d) +} + // ---------- commands ---------- fn check(s: &mut Session, p: &Value) -> Result { @@ -1337,7 +1411,9 @@ fn relink(s: &mut Session, p: &Value) -> Result { (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")))?) + // The document's path is not the client's: the error names it as the request may see it. + let no_folder = || fileio::naming_in_error(bad(C, format!("{doc} has no folder")), &doc); + Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(no_folder)?) } }; // An image or a placed document, with its link. @@ -1472,6 +1548,12 @@ fn relink(s: &mut Session, p: &Value) -> Result { not_found.dedup(); let mut out = json!({ "relinked": relinked, "notFound": not_found }); if copy_to.is_some() { + // The copies are in the document's folder, which the client did not give. + for pair in copied.iter_mut().chain(kept.iter_mut()) { + if let Some(to) = pair.get("to").and_then(Value::as_str).map(str::to_string) { + pair["to"] = json!(fileio::shown_path(&to)); + } + } out["copied"] = json!(copied); out["kept"] = json!(kept); } @@ -1521,9 +1603,14 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo use std::io::Write as _; let source = Path::new(from); let name = source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - // Each name it takes is checked against the automation roots in force. + // Each name it takes is checked against the automation roots in force. The folder is the + // document's, so a refusal names it and the file as the request may see them. let create = |to: &Path| { - crate::file_access::check_write(&to.to_string_lossy()).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + let to_text = to.to_string_lossy(); + crate::file_access::check_write(&to_text).map_err(|e| { + let e = fileio::naming_in_error(EngineError::Other(e), &to_text); + std::io::Error::new(std::io::ErrorKind::PermissionDenied, e.to_string()) + })?; vectorcraft_format::write_new_with(to, |f| f.write_all(bytes)) }; // A file there that automation may not read isn't taken for the same file. @@ -1534,7 +1621,10 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo Ok(Some(fileio::Placed::Copied(to))) => Ok((to.to_string_lossy().into_owned(), true)), Ok(Some(fileio::Placed::Kept(to))) => Ok((to.to_string_lossy().into_owned(), false)), Ok(None) => Err(EngineError::Other(format!("every numbered name for {name} is taken in the document's folder"))), - Err(e) => Err(EngineError::Other(format!("{}: {e}", dir.display()))), + Err(e) => { + let dir = dir.to_string_lossy(); + Err(fileio::naming_in_error(EngineError::Other(format!("{dir}: {e}")), &dir)) + } } } diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index d47e81befd..a208d527fa 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -389,6 +389,39 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result { }) } +pub(super) fn blend_set_easing(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.setEasing"; + let id = match crate::cmd::object_param(p, "id", C)? { + Some(id) => id, + None => *selected_of(s, is_blend).first().ok_or_else(|| bad(C, "select a blend or give `id`"))?, + }; + let doc = &s.doc()?.doc; + let Some(NodeKind::Blend { children, spec }) = doc.node(id).map(|n| &n.kind) else { return Err(bad(C, "not a blend")) }; + if !doc.is_editable(id) { + return Err(bad(C, "the object is hidden or locked")); + } + let ease = ease_param(p, C, spec.ease)?; + let color_shift = percent_param(p, C, "colorShift", spec.color_shift, -100.0)?; + // Easing moves the steps along a spine, so it needs one with a length to run along. + let easing = ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())); + let spined = spec.eases_along_spine() + && live::blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)).is_some_and(|sp| sp.length().is_finite() && sp.length() > 0.0); + if easing && !spined { + return Err(bad(C, "the blend has no spine")); + } + let changed = ease != spec.ease || color_shift != spec.color_shift; + if changed { + s.edit("Blend Options", |d, _| { + if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + spec.ease = ease; + spec.color_shift = color_shift; + } + Ok(()) + })?; + } + Ok(json!({ "id": id.0, "easingType": ease.kind.id(), "ramp": ease.ramp * 100.0, "colorShift": color_shift, "changed": changed })) +} + /// Spacing and orientation as `object.blend.options` takes them; the step count and distance /// not in use show their defaults. fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value { diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 1013c606c5..8e325c69be 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -1,6 +1,6 @@ use super::blend::{ blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, - blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, + blend_reverse_stack, blend_set_easing, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, }; use super::common::{has_blend, has_envelope, has_mesh}; use super::envelope::{ @@ -49,6 +49,15 @@ pub fn specs() -> Vec { always, blend_create_spine ), + cmd!( + "object.blend.setEasing", + "Blend Easing", + [], + None, + "{id? (default: the first selected blend), easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100)} set one blend's easing and color shift, as the blend widgets on the canvas do; omitted ones keep their value. easingType and ramp need a blend whose steps follow a spine (a replaced or edited spine, or Align to Path); a blend made without a spine takes them once Replace Spine has given it one; out of range, a hidden or locked blend and a blend with no spine are errors. A value that changes nothing records no undo step → {id, easingType, ramp, colorShift, changed}", + has_doc, + blend_set_easing + ), cmd!( "object.blend.expand", "Expand", diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index c3b24463ae..1aeba3ad30 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind}; use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target}; use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced}; @@ -718,7 +718,8 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result: } /// The message for a process color on a grayscale image with an alpha channel. -const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; +pub(super) const PROCESS_ON_TRANSPARENCY: &str = + "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; /// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch /// of `d`, black or white? @@ -737,11 +738,7 @@ pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> { let mut refused = false; let mut visit = |n: &Node| { vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { - if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref()) - && !refused - && !colorizes_transparency(d, paint) - && d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha) - { + if !refused && gray_colorized_with_process(d, m).is_some_and(|g| g.alpha) { refused = true; } }) @@ -761,6 +758,13 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R if was_ok { check_colorized(d) } else { Ok(()) } } +/// How the pixels of `n` are gray, when `n` is a grayscale or 1-bit image colorized with a color +/// that [`colorizes_transparency`] refuses. Such an image can't have an alpha channel. +pub(super) fn gray_colorized_with_process(d: &Document, n: &Node) -> Option { + let (NodeKind::Image(im), Some(paint)) = (&n.kind, n.extras().colorize.as_deref()) else { return None }; + if colorizes_transparency(d, paint) { None } else { d.image_blob(im).and_then(|b| b.gray_source()) } +} + /// Is `e` the refusal of [`check_colorized`]? pub(super) fn is_colorize_refusal(e: &EngineError) -> bool { e.to_string().contains(PROCESS_ON_TRANSPARENCY) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 83a0655284..325be0dd3c 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), @@ -483,6 +483,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "hyphenateCapitalized", "romanHangingPunctuation", "koreanLineBreakAnywhere", + "bunriKinshi", ], C, )?; @@ -536,6 +537,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "mojikumi", "kinsoku", "kinsokuType", + "bunriKinshi", "direction", "leadingModel", "charAlign", @@ -869,6 +871,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = kinsoku_type { para.kinsoku_type = v; } + if let Some(v) = flag("bunriKinshi") { + para.bunri_kinshi = v; + } if let Some(v) = direction { para.direction = v; } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 5b9e25d354..c617c4de8e 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -896,7 +896,7 @@ pub(crate) fn outlined_stroke( }; let art = d.reid(&art); let mut art = with_transparency(d, art, st.opacity, st.blend); - let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect(); + let raster = vectorcraft_render::effects::without_geometry(&st.effects); if !raster.is_empty() { if !art.appearance.effects.is_empty() { art = Node::group(d.alloc_id(), vec![Arc::new(art)]); @@ -946,7 +946,7 @@ pub(crate) fn outlined_stroke( /// geometry effects leave nothing to paint, an empty group takes the path's place. /// `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, reshape, without_geometry}; if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) { return None; } @@ -975,7 +975,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } let mut plain = moved; - plain.effects_mut().retain(|e| !is_geometry(&e.id)); + *plain.effects_mut() = without_geometry(plain.effects()); parts.push(styled_copy(d, &shape, vec![plain])); } AppearanceItem::Stroke(st) if outlinable(st) => { @@ -983,7 +983,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } let mut plain = st.clone(); - plain.effects.retain(|e| !is_geometry(&e.id)); + plain.effects = without_geometry(&st.effects); let stroked = match path.bounds() { Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)), _ => path.clone(), diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs index 4b8bc24e36..f4b91edd9b 100644 --- a/crates/engine/src/cmd/plugin_call.rs +++ b/crates/engine/src/cmd/plugin_call.rs @@ -169,6 +169,10 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[ ("document.exportForScreens", "folders"), ]; +/// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there, +/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`. +const NESTED_NOT_FILES: &[(&str, &[&str])] = &[("stroke.set", &["startArrow", "Custom", "path"]), ("stroke.set", &["endArrow", "Custom", "path"])]; + /// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`], /// [`FILE_WORDS`])? fn denied_id(id: &str) -> bool { @@ -232,20 +236,32 @@ pub(crate) fn names_key(text: &str, key: &str) -> bool { /// Does `args`, the arguments of a call of command `id`, carry a key in [`FILE_KEYS`] at any depth /// (in objects and in arrays), such as an effect's `textureImage.path`? Only a key [`NOT_FILES`] -/// names for `id`, at the top level, isn't counted. The walk keeps its own stack, so deep -/// arguments can't overflow the thread's. +/// names for `id`, at the top level, and one at the end of a [`NESTED_NOT_FILES`] key list for +/// `id`, aren't counted. The walk keeps its own stack, so deep arguments can't overflow the +/// thread's. fn names_file(id: &str, args: &Value) -> bool { - let mut stack = vec![(args, true)]; - while let Some((v, top)) = stack.pop() { + // Each value, whether it is the params themselves, and the keys of a NESTED_NOT_FILES list + // still to follow below it. + let mut stack: Vec<(&Value, bool, Option<&[&str]>)> = vec![(args, true, None)]; + while let Some((v, top, trail)) = stack.pop() { match v { - Value::Array(a) => stack.extend(a.iter().map(|x| (x, false))), + Value::Array(a) => stack.extend(a.iter().map(|x| (x, false, None))), Value::Object(o) => { for (k, x) in o { let k = k.as_str(); - if FILE_KEYS.contains(&k) && !(top && NOT_FILES.contains(&(id, k))) { + let rest = if top { + NESTED_NOT_FILES + .iter() + .find(|(c, keys)| *c == id && keys.first() == Some(&k)) + .map(|(_, keys)| keys.get(1..).unwrap_or_default()) + } else { + trail.filter(|keys| keys.first() == Some(&k)).map(|keys| keys.get(1..).unwrap_or_default()) + }; + let document = (top && NOT_FILES.contains(&(id, k))) || rest.is_some_and(<[&str]>::is_empty); + if FILE_KEYS.contains(&k) && !document { return true; } - stack.push((x, false)); + stack.push((x, false, rest.filter(|keys| !keys.is_empty()))); } } _ => {} @@ -585,6 +601,16 @@ mod tests { assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]}))); assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}}))); assert!(names_file("object.move", &json!({"path": [[0, 0]]}))); + // A custom arrowhead's outline in stroke.set, only where stroke.set takes one. + let outline = json!({"subpaths": []}); + assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}}))); + assert!(!names_file("stroke.set", &json!({"startArrow": {"Custom": {"name": "A", "path": outline}}, "endArrow": "Triangle"}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline, "file": "/tmp/x"}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"path": {"path": "/tmp/x"}}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"path": "/tmp/x"}}))); + assert!(names_file("stroke.set", &json!({"options": {"endArrow": {"Custom": {"path": "/tmp/x"}}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": [{"Custom": {"path": "/tmp/x"}}]}))); + assert!(names_file("object.move", &json!({"endArrow": {"Custom": {"path": "/tmp/x"}}}))); // Deep nesting is walked without recursion. let mut deep = json!({"file": "/tmp/x"}); for _ in 0..10_000 { diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 94f865e224..f25dfd4a35 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -242,6 +242,7 @@ pub(super) const ALLOWED: &[&str] = &[ "object.blend.replaceSpine", "object.blend.reverseFrontToBack", "object.blend.reverseSpine", + "object.blend.setEasing", "object.blend.spine.addAnchor", "object.blend.spine.convertAnchor", "object.blend.spine.moveAnchor", @@ -688,5 +689,6 @@ pub(super) const DENIED: &[&str] = &[ "object.blend.options", "object.liquify", "object.pathfinder.options", + "stroke.arrowheads.edit", "type.compositeFonts.set", ]; diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index af09b032da..c605cd39f3 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -156,6 +156,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ 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), + p!("boundingBoxWhileMovingAndDrawing", "General", "Options", "Show Bounding Box While Moving and Drawing", bool), // Selection & Anchor Display p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")), p!( diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index f0087595dd..c1c7f789fe 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -1163,6 +1163,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec, take: bool) -> Owner { +/// intervals but the one this session wrote taking it over. `held` names the gone apps' areas the +/// caller already holds. +fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec, take: bool, held: &[&str]) -> Owner { if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) { return Owner::Mine; } match store.lock(area) { Ok(Lock::Held(h)) => { // Copies an app left here when it had to leave the area stay its own while it runs. - if let Some(who) = keeper(s, store, area) { + if let Some(who) = keeper(s, store, area, held) { return who; } hold.push(h); @@ -1294,7 +1296,7 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) { return Owner::Running; } - if let Some(who) = keeper(s, store, area) { + if let Some(who) = keeper(s, store, area, held) { return who; } // Best effort: without it another app might offer the copies too, which loses nothing. @@ -1310,12 +1312,14 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< /// `/keeps-` ([`mark_left`]): this session, or another app that is running. `None`: /// no app that runs keeps them. An entry counts only in an area this account can lock or tell is /// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be -/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing). -fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option { +/// checked, keeps nothing. So does an entry in one of the gone apps' areas the caller holds +/// (`held`), whose lock this session would otherwise find taken. A store that can't be listed now +/// keeps them (a copy kept loses nothing). +fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str, held: &[&str]) -> Option { let Ok(names) = store.list() else { return Some(Owner::Running) }; let mark = format!("/{KEEPS}{area}"); let mut who = None; - for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) { + for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area && !held.contains(k)) { match holder(s, store, k) { Owner::Mine => return Some(Owner::Mine), Owner::Running => who = Some(Owner::Running), @@ -1377,7 +1381,7 @@ fn list(s: &mut Session, _: &Value) -> Result { let mut rows = vec![]; for area in store.areas().map_err(EngineError::Other)? { let mut hold = vec![]; - let who = owner(s, store.as_ref(), &area, &mut hold, false); + let who = owner(s, store.as_ref(), &area, &mut hold, false, &[]); let files: Vec<&String> = copies.iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect(); for f in &files { let meta = read_meta(store.as_ref(), f); @@ -1417,10 +1421,11 @@ fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) -> } let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c); let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect(); - let mut out = vec![]; + let mut out: Vec = vec![]; for area in areas { let mut hold = vec![]; - match (owner(s, store, area, &mut hold, true), file) { + let held: Vec<&str> = out.iter().map(|(a, _, _)| a.as_str()).collect(); + match (owner(s, store, area, &mut hold, true, &held), file) { (Owner::Gone, _) => { let files = copies.iter().filter(|(a, c)| a == area && picked(c)).map(|(_, c)| c.clone()).collect(); out.push((area.to_string(), files, hold)); diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 44a14dbe34..f5edf1f91d 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -297,7 +297,8 @@ fn list_installed(s: &mut Session, _: &Value) -> Result { return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false})); }; let folder = root.to_string_lossy(); - crate::file_access::check_read(&folder).map_err(EngineError::Other)?; + // The Scripts folder is the app's: a refusal names it as the request may see it. + crate::file_access::check_read(&folder).map_err(|e| super::fileio::naming_in_error(EngineError::Other(e), &folder))?; let listing = script::folder::list(root); let scripts: Vec = listing .scripts() diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 44ec938bc2..228251fa13 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -2,7 +2,9 @@ //! 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, object_param, point_param}; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, number_or, object_param, point_param, +}; use serde_json::{Value, json}; use std::{ collections::{BTreeMap, HashMap}, @@ -34,7 +36,7 @@ pub fn specs() -> Vec { "Select Shaper Face", [], None, - "{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}", + "{point: [x,y], source?: bool, tolerance?: pt (3)} click selects a group, then a face; with the group or one of its faces selected, a click within tolerance of an original shape's outline enters construction isolation and selects that original; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}", has_doc, select ), @@ -734,16 +736,50 @@ fn group_at(d: &Document, point: Point, construction: bool, within: Option Option<(NodeId, NodeId)> { + let mut hit = None; + let editable = d.editable(); + d.walk(|n| { + if n.extras().shaper.is_none() || !editable(n.id) { + return; + } + let picked = selection.contains(n.id) || selection.objects.iter().any(|id| d.parent_of(*id) == Some(n.id)); + let on_outline = + |s: &&Arc| b::node_outline(s).is_some_and(|(path, _)| path.nearest(point).is_some_and(|(_, _, _, _, dist)| dist <= tolerance)); + let source = if picked { sources(n).iter().rev().filter(|s| s.visible && !s.locked).find(on_outline).map(|s| s.id) } else { None }; + if let Some(source) = source { + hit = Some((n.id, source)); + } else if n.geometric_bounds().is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) { + hit = None; + } + }); + hit +} fn select(s: &mut Session, p: &Value) -> Result { let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?; let st = s.doc()?; let source = bool_or(p, "source", false); + let tolerance = number_or(p, "tolerance", 3.0, "shaper.select")?; + if !tolerance.is_finite() || !(0.0..=1000.0).contains(&tolerance) { + return Err(bad("shaper.select", "invalid tolerance")); + } // In Construction mode a source pick stays in the group whose source shapes are isolated. let isolated = st.isolation.and_then(|i| st.doc.parent_of(i)).filter(|g| st.doc.node(*g).and_then(source_group).is_some_and(|s| st.isolation == Some(s.id))); - let Some(group) = group_at(&st.doc, point, source, isolated.filter(|_| source)) else { return Ok(json!({"id": null})) }; + // With a group or one of its faces selected, a click on one of its original shapes' outlines + // enters Construction mode with the topmost such shape selected, wherever the outline lies. + let outline = if source { None } else { outline_at(&st.doc, &st.selection, point, tolerance) }; + let Some(group) = outline.map(|(g, _)| g).or_else(|| group_at(&st.doc, point, source, isolated.filter(|_| source))) else { + return Ok(json!({"id": null})); + }; let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?; - let target = if source { + let source = source || outline.is_some(); + let target = if let Some((_, id)) = outline { + id + } else if source { sources(node) .iter() .rev() diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 4153fedd51..fe846ad8af 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -4,8 +4,10 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile, + ArrowAlign, Arrowhead, CharStyle, CustomArrowhead, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, + Unit, WidthProfile, }; +use vectorcraft_geom::PathData; use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; @@ -20,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -242,6 +244,34 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { None => Ok(None), Some(Value::Null) => Ok(Some(None)), Some(Value::String(n)) if n == "none" => Ok(Some(None)), + // A custom head by name, from the arrowheads file (stroke.arrowheads.list). + Some(Value::Object(o)) if o.len() == 1 && o.contains_key("custom") => { + let Some(Value::String(name)) = o.get("custom") else { return Err(bad(C, format!("{k}: custom must be a name"))) }; + let head = s.arrowheads.get(name).ok_or_else(|| bad(C, format!("no custom arrowhead {name} (see stroke.arrowheads.list)")))?; + Ok(Some(Some(Arrowhead::Custom(head)))) + } + // A custom head with its art: an outline the head would drop is refused, not drawn empty. + Some(Value::Object(o)) if o.contains_key("Custom") => { + let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null); + let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?; + // The region the outline fills (non-zero rule), down to slivers a millionth of a + // head unit thin. Measured only once `fits` has bounded the anchors. + let fills = |p: &PathData| { + let area = vectorcraft_pathops::unite_all_with(&[(p, vectorcraft_geom::FillRule::NonZero)], 1e-6); + area.subpaths.iter().map(|sp| sp.area().abs()).sum::() > 1e-12 + }; + if !CustomArrowhead::fits(&path) || !fills(&path) { + let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS); + return Err(bad( + C, + format!( + "{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip" + ), + )); + } + let head = p.get(k).cloned().unwrap_or(Value::Null); + serde_json::from_value::(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}"))) + } Some(v) => serde_json::from_value::(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))), } }; @@ -258,11 +288,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { }; let set = |st: &mut StrokeLayer| { change.apply(st); - if let Some(a) = sa { - st.start_arrow = a; + if let Some(a) = &sa { + st.start_arrow = a.clone(); } - if let Some(a) = ea { - st.end_arrow = a; + if let Some(a) = &ea { + st.end_arrow = a.clone(); } if let Some(a) = arrow_align { st.arrow_align = a; diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 45320066cf..8101cfff97 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "Copy to User Defined", ["Window", "Swatch Libraries"], None, - "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}", + "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}", has_user_folder, copy_to_user ), @@ -368,7 +368,9 @@ impl Libraries { .ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?; let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) { Some(p) => { - // Outside the request's read roots: the error names no path. + // The file's bytes are read: outside the request's read roots, the error names no + // path. A document's swatches (the arm below) are written from the loaded library, + // and the document is not read again. if !readable(&p) { return Err(bad(cmd, "the library's file is outside the read roots")); } @@ -469,19 +471,34 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) } +/// The file name a concealed library or path is shown by: the last part of `path` as this platform +/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute +/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one +/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part. +pub fn concealed_file_name(path: &str) -> String { + let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten(); + native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned()) +} + /// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without -/// roots in force. +/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is +/// never readable under a read root. fn readable(path: &str) -> bool { - crate::file_access::check_read(path).is_ok() + !crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok() } /// The loaded library ids (`loaded/`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/`, as a library loaded without a path is -/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then -/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free -/// one of `loaded/ (2)`, `(3)` and so on is shown, so that two libraries stay two. The -/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows -/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none. +/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute +/// path counts as one the request may not read. The ids are those of the +/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers +/// to). When another id already is or shows that id, the next free +/// one of `loaded/ (2)`, `(3)` and so on is shown, so that two libraries stay two. A library +/// keeps the id it was first shown under for the rest of the session, so an id a client was given +/// finds the same library after another one with the same file name is loaded; only a library that +/// shows that id itself (one loaded later without a file under that name) moves it to the next free +/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of +/// their full ids, then `others`, in the same order. Without roots in force, none. pub fn concealed_library_ids(s: &Session, others: impl IntoIterator) -> Vec<(String, String)> { if crate::file_access::current().is_none() { return vec![]; @@ -493,7 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator hidden.push((id, name)), _ => { @@ -502,16 +519,32 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator = assigned.values().cloned().collect(); hidden .into_iter() - .map(|(id, name)| { - let mut shown = format!("loaded/{name}"); - let mut n = 2; - while taken.contains(&shown) { - shown = format!("loaded/{name} ({n})"); - n += 1; - } - taken.insert(shown.clone()); + .zip(kept) + .map(|((id, name), kept)| { + let shown = kept.unwrap_or_else(|| { + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) || given.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); + given.insert(shown.clone()); + assigned.insert(id.clone(), shown.clone()); + shown + }); (id, shown) }) .collect() @@ -746,8 +779,12 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result { "user" => (info, lib, false), _ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))), }; - let path = s.swatch_libraries.shown_path(&info.id); - Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied})) + let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied}); + // As the saves with `user: true`: no `path` when the request may not read the file. + if let Some(path) = s.swatch_libraries.shown_path(&info.id) { + out["path"] = json!(path); + } + Ok(out) } /// The library swatches `names` stand for, each once, with the colour group it goes into (`None`: diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 891e518a33..eae92d87af 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?: pt 0.1..1296 (outside it: an error), font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", has_doc, create_in_path ), @@ -717,7 +717,7 @@ impl CharChange { let c = Self { font: text_param(p, "font", cmd)?.map(str::to_string), style: text_param(p, "style", cmd)?.map(str::to_string), - size: num("size"), + size: num("size").map(|v| super::typecmd::given_type_size(v, cmd)).transpose()?, leading: auto_or_num(p, "leading", cmd)?, tracking: num("tracking"), kerning: match p.get("kerning") { @@ -768,9 +768,6 @@ impl CharChange { } }, }; - if c.size.is_some_and(|v| v <= 0.0) { - return Err(bad(cmd, "size must be positive")); - } Ok(c) } @@ -1644,7 +1641,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["size"], C)?; check_switches(p, &["vertical", "placeholder"], C)?; super::color_param(p, "color", C)?; - let style = super::create::new_type_style(s, p); + let style = super::create::new_type_style(s, p, C)?; let text = str_param(p, "text").unwrap_or("").to_string(); let start = match point_param(p, "at") { Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0), diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 40d8af22ad..13c2e9e05b 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -243,6 +243,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("burasagari", json!(v.burasagari)), ("kinsoku", json!(v.kinsoku)), ("kinsoku_type", json!(v.kinsoku_type)), + ("bunri_kinshi", json!(v.bunri_kinshi)), ("roman_hanging", json!(v.roman_hanging)), ("kashida", json!(v.kashida)), ("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index e71267a2de..e996591f91 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -138,7 +138,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_style ), @@ -715,6 +715,20 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result Result { + let (lo, hi) = TYPE_SIZES; + if !(lo..=hi).contains(&size) { + let key = "size"; + return Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))); + } + Ok(size) +} + /// Clamp in the units shown to the user, then compensate the text's affine. Clamping /// the local result would make small sizes impossible after enlarging an object. pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result { @@ -820,10 +834,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setStyle"; let font = text_param(p, "font", C)?.map(str::to_string); let style = text_param(p, "style", C)?.map(str::to_string); - let size = length_given(p, "size", C)?; - if size.is_some_and(|v| v <= 0.0) { - return Err(bad(C, "size must be positive")); - } + let size = length_given(p, "size", C)?.map(|v| given_type_size(v, C)).transpose()?; let leading = match p.get("leading") { None | Some(Value::Null) => None, Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None), diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 5545e1be94..2c7d61bb0a 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -440,6 +440,59 @@ fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() { assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); } +/// Paragraph panel menu › Bunri-Kinshi: off for new type and for a paragraph saved before it, and +/// saved only when on; `text.setFormat {bunriKinshi}` sets it per paragraph in one undo step and +/// refuses a value that is not true or false, kinsoku None keeps it, and paragraph styles carry it. +#[test] +fn bunri_kinshi_is_set_per_paragraph_and_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "あ——い\nう……え"})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert!(!text(&s).para_at(0).bunri_kinshi, "new type has it off"); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("bunri_kinshi").is_none()); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert!(!old.bunri_kinshi, "saved before the option: off"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + // Only the second paragraph. + let second = "あ——い\n".len(); + s.execute("text.setFormat", &json!({"bunriKinshi": true, "start": second, "end": second})).unwrap(); + assert!(!text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let saved = serde_json::to_value(text(&s).para_at(1)).unwrap(); + assert_eq!(saved["bunri_kinshi"], true); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(saved).unwrap(); + assert!(back.bunri_kinshi); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert!(!back.para_at(0).bunri_kinshi && back.para_at(1).bunri_kinshi, "the file keeps it per paragraph"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!text(&s).para_at(1).bunri_kinshi); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + for bad in [json!("yes"), json!(1)] { + assert!(s.execute("text.setFormat", &json!({"ids": [id], "bunriKinshi": bad, "kinsoku": "none"})).is_err(), "{bad}"); + } + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "bunriKinshi": false})).is_err()); + assert!(text(&s).para_at(1).bunri_kinshi && text(&s).para_at(1).kinsoku != vectorcraft_doc::Kinsoku::None, "a refused call changes nothing"); + // Kinsoku None keeps the setting, without effect, and turning kinsoku back on restores it. + s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "none"})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "hard"})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Pairs", "attrs": {"bunri_kinshi": true}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Pairs", "id": id})).unwrap(); + assert!(text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi); + s.execute("paraStyle.new", &json!({"name": "Split", "attrs": {"bunri_kinshi": false}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Split", "id": id})).unwrap(); + assert!(!text(&s).para_at(0).bunri_kinshi && !text(&s).para_at(1).bunri_kinshi); +} + /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one /// undo step, the paragraph is saved with it only when it is not None and opens with it, an /// unknown value changes nothing, and paragraph styles carry it. diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index ad70f047bc..241487112b 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -27,6 +27,7 @@ mod csv; mod library; use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import}; +pub(crate) use library::{give_svg_library, svg_library}; use library::{load_library, save_library}; /// The longest variable or dataset name, in characters (names arrive from MCP and files). diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs index 54f3689e99..e1f4ae0bd5 100644 --- a/crates/engine/src/cmd/variablecmds/library.rs +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -1,6 +1,8 @@ +use std::collections::HashMap; + use super::{ - BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, absolute_path, bad, json, named, - read_file_head, str_param, variables_unlocked, write_file, + BTreeMap, BTreeSet, DataSet, DataValue, Document, EngineError, MAX_NAME, NodeId, NodeKind, Result, Session, Value, Variable, VariableKind, + Variables, absolute_path, bad, json, named, read_file_head, str_param, variables_unlocked, write_file, }; /// The namespace of a variable library's elements, the default namespace of its data sets' values, @@ -169,12 +171,20 @@ fn without_doctype(xml: &str, cmd: &str) -> Result { } /// A variable library as Load Variable Library reads it: the names of its text variables in the -/// order it declares them, the name and `trait` of each variable of another kind, and its data -/// sets with their text values. -struct Library { +/// order it declares them, the name and `trait` of each variable of another kind, its data sets +/// with their text values, and the element id each text variable's `docRef` names. +pub(crate) struct Library { text: Vec, other: Vec<(String, String)>, datasets: Vec, + refs: Vec<(String, String)>, +} + +impl Library { + /// The element ids the text variables' `docRef`s name. + pub(crate) fn elements(&self) -> std::collections::HashSet { + self.refs.iter().map(|(_, e)| e.clone()).collect() + } } /// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each @@ -200,7 +210,7 @@ fn parse_library(xml: &str, cmd: &str) -> Result { return Err(bad(cmd, "the file holds no `variableSets` element")); }; let sets: Vec = root.children().filter(|n| is(n, "variableSet")).collect(); - let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new()); + let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::new(), Vec::new()); for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { let name = named(var.attribute("varName"), "varName", cmd)?; if !declared.insert(name.clone()) { @@ -210,7 +220,14 @@ fn parse_library(xml: &str, cmd: &str) -> Result { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); } match var.attribute("trait") { - Some("textcontent") => text.push(name), + Some("textcontent") => { + // `docRef="id('…')"` names the element of the variable's bound object. + let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); + if let Some(element) = element { + refs.push((name.clone(), element.to_string())); + } + text.push(name); + } Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())), None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))), } @@ -253,7 +270,152 @@ fn parse_library(xml: &str, cmd: &str) -> Result { } datasets.push(DataSet { name, values }); } - Ok(Library { text, other, datasets }) + Ok(Library { text, other, datasets, refs }) +} + +/// The variable library an SVG carries (SVG Options › Include Variable Data), read as +/// [`parse_library`] reads one, or `None` when the SVG holds no `variableSets` element. The +/// element is read on its own, from its start tag to the end tag that closes it +/// ([`variable_sets_range`]), with each reference to an +/// entity whose name starts with `ns_` replaced by the value the text before the element declares +/// for it (up to [`MAX_LIBRARY_ENTITIES`] of them, plain text of at most [`MAX_NAME`] bytes), as +/// files name the library's namespaces with such entities. The `ns_vars`, `ns_custom` and +/// `ns_flows` entities take the library's namespaces when the file doesn't declare them. An +/// element larger than [`MAX_LIBRARY_BYTES`], before or after the replacements, is refused. +pub(crate) fn svg_library(svg: &str) -> Option> { + const C: &str = "document.open"; + let (start, end) = variable_sets_range(svg)?; + let read = || { + let end = end.ok_or_else(|| bad(C, "the variable library declares no variables"))?; + let element = svg.get(start..end).unwrap_or_default(); + if element.len() > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut entities: Vec<(String, String)> = Vec::new(); + for decl in svg.get(..start).unwrap_or_default().split("(); + if element.len() + grown > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut element = element.to_string(); + for (name, value) in &entities { + element = element.replace(name.as_str(), value); + } + parse_library(&format!("{element}"), C) + }; + Some(read()) +} + +/// Where the first `variableSets` element of `svg` that is markup starts, and where the end tag +/// that closes it ends (`None` when no end tag does), or `None` when there is no such element. +/// Comments, CDATA sections, processing instructions and the document type declaration are +/// skipped, so text inside them that looks like the element is not taken for it. +fn variable_sets_range(svg: &str) -> Option<(usize, Option)> { + const NAME: &str = "variableSets"; + // The element's name ends at white space, `>` or `/`. + let named = |rest: &str| rest.strip_prefix(NAME).is_some_and(|r| r.starts_with(|c: char| c.is_ascii_whitespace() || matches!(c, '>' | '/'))); + if !svg.contains("") + } else if rest.starts_with("") + } else if rest.starts_with("") + } else if rest.starts_with("`. + match (rest.find('['), rest.find('>')) { + (Some(open), Some(gt)) if open < gt => { + rest.get(open..).and_then(|s| s.find(']')).and_then(|c| rest.get(open + c..)?.find('>').map(|g| at + open + c + g + 1)) + } + (_, gt) => gt.map(|g| at + g + 1), + } + } else if let Some(close) = rest.strip_prefix(""); + if let Some(s) = start + && named(close) + { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some((s, tag_end)); + } + } + tag_end + } else { + let tag_end = past(">"); + if named(rest.get(1..).unwrap_or_default()) { + let empty = tag_end.and_then(|e| svg.get(..e)).is_some_and(|t| t.ends_with("/>")); + if start.is_none() { + start = Some(at); + } + if empty && depth == 0 { + return Some((at, tag_end)); + } + depth += usize::from(!empty); + } + tag_end + }; + let Some(next) = next else { break }; + at = next; + } + start.map(|s| (s, None)) +} + +/// Give `doc`, opened from an SVG, the variable data `lib` it carries ([`svg_library`]): the +/// library's text variables, its data sets, no active data set, and each text variable bound to +/// the type object made of the element its `docRef` names, or the one type object among the +/// objects a group made of it holds (`found`: the object made of each element, by id). A `docRef` +/// that names no type object, or one another variable is bound to already, binds nothing. The art +/// stays as the file draws it. The warning about the variables of other kinds that were left out, +/// if any. +pub(crate) fn give_svg_library(doc: &mut Document, lib: Library, found: &HashMap) -> Option { + let mut vars = + Variables { variables: lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(), ..Variables::default() }; + vars.datasets = lib.datasets; + // A type object written with a paint of its own, or with fills and strokes of its appearance, + // is a group around its text (and the appearance's paths). + let text_of = |id: NodeId| match doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(_)) => Some(id), + Some(NodeKind::Group { children, .. }) => { + let mut texts = children.iter().filter(|c| matches!(c.kind, NodeKind::Text(_))); + match (texts.next(), texts.next()) { + (Some(one), None) => Some(one.id), + _ => None, + } + } + _ => None, + }; + for (name, element) in &lib.refs { + let Some(id) = found.get(element).and_then(|id| text_of(*id)) else { continue }; + if !vars.bindings.contains_key(&id) { + vars.bind(id, name); + } + } + doc.variables = vars.into(); + left_out_warning(lib.other.iter().map(|(_, t)| t.as_str())) } /// Load Variable Library: replace the document's variables and data sets with a variable library's diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 95b44776f0..c9de286514 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -84,7 +84,7 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { /// Queries that apply the edit the active tool is computing before they run, as edits do /// ([`Session::finish_tool_edit`]): a batch runs edits, Find Objects with `select`, Select All /// Instances and Lock Guides change the selection, and the proof views, the preferences and added -/// fonts give the documents new revisions (they draw again, and type lays out again). +/// fonts can give the documents new revisions (they draw again, and type lays out again). const WAIT_FOR_TOOL_EDIT: &[&str] = &[ "script.run", "command.batch", diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 73c8ba8efe..6ca724d810 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -215,6 +215,20 @@ pub fn check_read(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Read)) } +/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one +/// that starts with a drive (`C:`) or a `\`? [`check_read`] reads it as a name in the working folder. +pub fn written_elsewhere(path: &str) -> bool { + let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + !cfg!(windows) && foreign && current().is_some() +} + +/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`, +/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, +/// `destinationFolder`)? +pub fn is_path_key(key: &str) -> bool { + matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end)) +} + /// May `path` be written here? Always, without roots in force. pub fn check_write(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Write))?; diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 58832adf29..146583778f 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -287,7 +287,8 @@ fn document_base(s: &Session) -> Value { "title": st.title(), // The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name. "fileInfoTitle": d.info_title(), - "path": st.path, + // Null for a confined caller when the file is outside its read roots. + "path": st.path.as_deref().filter(|p| crate::file_access::check_read(p).is_ok()), "dirty": st.is_dirty(), "revision": st.revision, "units": d.units.label(), diff --git a/crates/engine/src/interaction.rs b/crates/engine/src/interaction.rs index 1de58228a2..e3cd563d55 100644 --- a/crates/engine/src/interaction.rs +++ b/crates/engine/src/interaction.rs @@ -27,6 +27,9 @@ pub struct Interaction { /// A transform or a perspective edit with `copy` keeps the copies it made of the snapshot here /// ([`PreviewCopies`]). pub(crate) copies: Option>, + /// The bounding box of `selection` in `doc`, measured once for a move drag + /// ([`crate::Session::move_drag_box`]): each step offsets it by the drag's move. + pub(crate) move_box: std::sync::OnceLock>, } /// What Object → Transform → Transform Again (⌘D) repeats ([`SessionState::last_transform`]). diff --git a/crates/engine/src/journal.rs b/crates/engine/src/journal.rs index 230bcfba0d..c9a2547b3c 100644 --- a/crates/engine/src/journal.rs +++ b/crates/engine/src/journal.rs @@ -25,6 +25,8 @@ pub struct Journal { bytes: usize, /// Keep every entry from this number on ([`Journal::hold`]). held: Option, + /// How many times kept entries were changed, dropped or renumbered ([`Journal::rewrites`]). + rewrites: u64, } /// Journals with the same entries (by number) are equal, whatever their history. @@ -66,14 +68,24 @@ impl Journal { self.entries.get(i.checked_sub(self.dropped)?) } pub fn get_mut(&mut self, i: usize) -> Option<&mut JournalEntry> { + self.rewrites += 1; self.entries.get_mut(i.checked_sub(self.dropped)?) } pub fn last(&self) -> Option<&JournalEntry> { self.entries.last() } pub fn last_mut(&mut self) -> Option<&mut JournalEntry> { + self.rewrites += 1; self.entries.last_mut() } + /// A number that grows whenever a kept entry may have changed, gone or been renumbered + /// ([`Journal::truncate`], [`Journal::remove`], [`Journal::replace`], [`Journal::get_mut`], + /// [`Journal::last_mut`]), and that [`Journal::push`] and letting old entries go leave as it + /// is. While it stays the same, the entries from a held number on are the ones counted before, + /// with the new ones after them. + pub fn rewrites(&self) -> u64 { + self.rewrites + } pub fn push(&mut self, e: JournalEntry) { let size = entry_bytes(&e); self.entries.push(e); @@ -83,6 +95,7 @@ impl Journal { } /// Drop the entries from number `len` on. pub fn truncate(&mut self, len: usize) { + self.rewrites += 1; let keep = len.saturating_sub(self.dropped); self.entries.truncate(keep); self.sizes.truncate(keep); @@ -92,6 +105,7 @@ impl Journal { /// Drop the entries numbered `numbers` (sorted, a number may repeat; the entries of canceled undo /// groups). The entries after them are numbered down to close the gaps. pub fn remove(&mut self, numbers: &[usize]) { + self.rewrites += 1; let dropped = self.dropped; let gone = |i: usize| numbers.binary_search(&(dropped + i)).is_ok(); let mut i = 0; @@ -115,6 +129,7 @@ impl Journal { } /// Put `e` in place of entry number `i`, if kept. pub fn replace(&mut self, i: usize, e: JournalEntry) { + self.rewrites += 1; let Some(k) = i.checked_sub(self.dropped) else { return }; if let (Some(old), Some(size)) = (self.entries.get_mut(k), self.sizes.get_mut(k)) { *size = entry_bytes(&e); @@ -253,4 +268,29 @@ mod tests { j.replace(29_000, big(0)); assert_eq!(j.bytes, j.iter().map(entry_bytes).sum::()); } + + #[test] + fn rewrites_count_changes_to_kept_entries_but_not_new_ones() { + let mut j = Journal::default(); + j.hold(Some(0)); + for i in 0..100 { + j.push(("object.move".into(), json!({"dx": i}))); + } + assert_eq!(j.rewrites(), 0, "pushes add entries after the counted ones"); + let changes: [fn(&mut Journal); 5] = [ + |j| j.truncate(90), + |j| j.remove(&[3]), + |j| j.replace(4, ("object.group".into(), json!({}))), + |j| { + let _ = j.get_mut(5); + }, + |j| { + let _ = j.last_mut(); + }, + ]; + for (i, change) in changes.into_iter().enumerate() { + change(&mut j); + assert_eq!(j.rewrites(), i as u64 + 1); + } + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e783f4353f..a891fb9599 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -151,6 +151,11 @@ pub struct Session { pub symbol_libraries: cmd::symbollib::Libraries, /// User Defined and loaded brush libraries (Window → Brush Libraries); not saved. pub brush_libraries: cmd::brushlib::Libraries, + /// The custom arrowheads of the arrowheads file (`stroke.arrowheads.*`); none without one. + pub arrowheads: cmd::arrowheads::Arrowheads, + /// The id each loaded library outside a request's read roots has been shown under, by its own id + /// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session. + shown_library_ids: std::sync::Mutex>, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, /// Where the saved PDF presets are kept, one file each; `None`: only for the session diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index aa8b5c7f42..52c4b7a4f2 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -71,6 +71,11 @@ pub struct Prefs { /// 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, + /// With View › Show Bounding Box on, the bounding box stays visible while a drag moves the + /// selection, and the shape tools that show it draw it around the shape being drawn. Off (the + /// default), it hides during a move and while a shape is drawn, except that a Selection tool + /// move with Real-time Drawing off still moves the box. + pub bounding_box_while_moving_and_drawing: bool, /// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this). pub paste_offset: f64, // Selection & Anchor Display @@ -397,6 +402,7 @@ impl Default for Prefs { scrub_numeric_fields: true, trackpad_rotate_view: true, hold_tool_keys: true, + bounding_box_while_moving_and_drawing: false, paste_offset: 10.0, selection_tolerance: 3.0, marquee_selection: "touching".into(), diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 0da75a42ed..d1265172ae 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -723,7 +723,7 @@ struct Host { /// order: the error's mark ([`HostError::Marked`]) is its index. eval_failures: RefCell, u32, u32)>>, /// The realm's tag for the script ([`Realm::script_tag`]) once the run has started it, and - /// [`vectorcraft_js::UNKNOWN_SOURCE`] before. + /// [`vectorcraft_js::UNKNOWN_SOURCE`] before or when the script did not compile. script_tag: Cell, /// The run's name as its report gives it, which [`Host::name`] shortens for console lines. report_name: Arc, @@ -1865,7 +1865,10 @@ impl Session { .and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() }) .and_then(|()| { let r = realm.start(&expanded.source); - host.script_tag.set(realm.script_tag()); + // Source that does not compile defines no code, so its map is not kept. + if !matches!(&r, Err(e) if e.kind == RunErrorKind::Syntax) { + host.script_tag.set(realm.script_tag()); + } r }) .map_err(|e| located(&host, &realm, &e)) diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 0ee7ab2a8a..3e9ae56423 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -80,6 +80,8 @@ impl Session { style_libraries: Default::default(), symbol_libraries: Default::default(), brush_libraries: Default::default(), + arrowheads: Default::default(), + shown_library_ids: Default::default(), libraries: Default::default(), pdf_preset_dir: None, dictionary_file: None, diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index c7d22ab6c7..61a4cc8a01 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -267,6 +267,7 @@ impl Session { isolation: st.isolation, perspective_again: None, copies: None, + move_box: std::sync::OnceLock::new(), }); Ok(()) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3363bb4a0c..18eadcdf80 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9,6 +9,7 @@ pub(super) mod adjust; mod ai_open_path; mod appearance; mod areafit; +mod arrowheads; mod assets; mod assets_3d; mod attributes; diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs new file mode 100644 index 0000000000..b350341767 --- /dev/null +++ b/crates/engine/src/tests/arrowheads.rs @@ -0,0 +1,365 @@ +//! Custom arrowheads (`cmd/arrowheads.rs`): heads read from the arrowheads file's symbols, listed, +//! set with `stroke.set`, reloaded when the file is saved, and kept with the documents that use +//! them. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Arrowhead, Document, Node, StrokeLayer, Symbol, SymbolRegistration}; +use vectorcraft_geom::{PathData, Point, SubPath}; + +use crate::cmd::arrowheads::{self, heads_of}; +use crate::*; + +fn polygon(pts: &[(f64, f64)], ap: Appearance) -> Node { + let pts: Vec = pts.iter().map(|&(x, y)| Point::new(x, y)).collect(); + Node::path(NodeId(0), PathData::single(SubPath::polyline(&pts, true)), ap) +} + +fn add_symbol(d: &mut Document, name: &str, art: Node) { + let art = d.reid(&art); + d.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); +} + +fn filled() -> Appearance { + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0) +} + +/// A folder of its own for test `name`, emptied. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-arrowheads-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn session_with_line() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +fn stroke(s: &Session, id: NodeId) -> StrokeLayer { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone() +} + +#[test] +fn each_symbol_is_a_head_pointing_right_with_its_tip_at_the_registration_point() { + let mut d = Document::new(100.0, 100.0); + // 20 high, so a unit is 5 pt: tip on the right edge's middle. + add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + d.set_symbol_registration("Wedge", SymbolRegistration(5)); + // A stroke alone paints its outline: a 10 × 4 band, tip at its centre (the default). + let mut line = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0); + line.stroke_mut().unwrap().cap = vectorcraft_doc::LineCap::Butt; + let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), line); + add_symbol(&mut d, "Band", band); + // Neither filled nor stroked: no head. + add_symbol(&mut d, "Ghost", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], Appearance::default())); + // Far longer than wide. + add_symbol(&mut d, "Needle", polygon(&[(0.0, 0.0), (1000.0, 0.5), (0.0, 1.0)], filled())); + // A fill at 0 % opacity paints nothing. + let mut clear = filled(); + if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = clear.items.first_mut() { + f.opacity = 0.0; + } + add_symbol(&mut d, "Clear", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], clear)); + // More anchors than a symbol's art may have: skipped before its shapes are united. + let dense: Vec<(f64, f64)> = (0..=arrowheads::MAX_ART_ANCHORS).map(|i| ((i as f64).cos(), (i as f64).sin())).collect(); + add_symbol(&mut d, "Dense", polygon(&dense, filled())); + // A 20 × 4 bar clipped to a 4 × 4 square: a 4 × 4 head. + let mut mask = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut mask.kind { + *clipping = true; + } + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + add_symbol(&mut d, "Clipped", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(bar)], clip: true })); + // An 8 × 4 bar grown by 2 pt on every side (Offset Path): 12 × 8, so 6 units long. + let mut grown = filled(); + grown.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], grown)); + // The same effect on the fill alone. + let mut fill_grown = filled(); + fill_grown.items[0].effects_mut().push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Fill Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], fill_grown)); + // And on a group holding the bar. + let mut group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], filled()))]); + group.appearance.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Group Grown", group); + // A clipping path stroked 2 pt: its stroke paints unclipped and does not widen the clip. + let mut frame = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0)); + if let NodeKind::Path { clipping, .. } = &mut frame.kind { + *clipping = true; + } + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + add_symbol(&mut d, "Framed", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(frame), Arc::new(bar)], clip: true })); + // A group's own fill paints its unpainted 8 × 4 member. + let mut painted_group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], Appearance::default()))]); + painted_group.appearance = filled().into(); + add_symbol(&mut d, "Group Fill", painted_group); + // A 10 × 4 band stroked with a gradient along it. + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + let mut along = Appearance::basic(Paint::None, gradient, 4.0); + if let Some(st) = along.stroke_mut() { + (st.cap, st.gradient_mode) = (vectorcraft_doc::LineCap::Butt, vectorcraft_doc::StrokeGradientMode::Along); + } + let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), along); + add_symbol(&mut d, "Gradient Band", band); + // An 8 × 4 mesh of two patches whose right one is fully transparent: a 4 × 4 head. + let corners = vectorcraft_doc::live::grid_points(vectorcraft_geom::Rect::new(0.0, 0.0, 8.0, 4.0), 1, 2); + let points = corners + .iter() + .map(|&p| vectorcraft_doc::MeshPoint { p, color: Color::BLACK, opacity: if p.x > 1.0 { 0.0 } else { 1.0 }, handles: Default::default() }) + .collect(); + let mesh = vectorcraft_doc::GradientMesh { rows: 1, cols: 2, points }; + add_symbol(&mut d, "Half Mesh", Node::new(NodeId(0), NodeKind::Mesh(mesh))); + // An effect on the file's layer is not part of any symbol. + Arc::make_mut(&mut d.layers[0]).appearance.effects.push(vectorcraft_doc::Effect { + id: "path.offsetPath".into(), + params: json!({"offset": 5}), + visible: true, + }); + let read = heads_of(&d); + let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect(); + assert_eq!(names, ["Wedge", "Band", "Clipped", "Grown", "Fill Grown", "Group Grown", "Framed", "Group Fill", "Gradient Band", "Half Mesh"]); + let b = read.heads[9].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the mesh's painted patch: {b:?}"); + let b = read.heads[7].path.bounds().unwrap(); + assert!((b.width() - 8.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the group's fill: {b:?}"); + let (g, plain) = (read.heads[8].path.bounds().unwrap(), read.heads[1].path.bounds().unwrap()); + let near = |a: f64, b: f64| (a - b).abs() < 1e-3; + assert!(near(g.x0, plain.x0) && near(g.x1, plain.x1) && near(g.y0, plain.y0) && near(g.y1, plain.y1), "the gradient stroke's outline: {g:?}"); + let framed = read.heads[6].path.to_bezpath(); + let b = vectorcraft_geom::Shape::bounding_box(&framed); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "6 × 6 with the stroke: {b:?}"); + assert_ne!(vectorcraft_geom::Shape::winding(&framed, Point::new(0.1, 0.1)), 0, "the clipped bar fills the middle"); + for h in &read.heads[3..6] { + let b = h.path.bounds().unwrap(); + assert!((b.width() - 6.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "{}: the effect's outline: {b:?}", h.name); + } + let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(skipped, ["Ghost", "Needle", "Clear", "Dense"], "{:?}", read.skipped); + let b = read.heads[2].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "clipped to the square: {b:?}"); + let b = read.heads[0].path.bounds().unwrap(); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 4.0).abs() < 1e-6 && (b.y0 + 2.0).abs() < 1e-6 && (b.y1 - 2.0).abs() < 1e-6, "{b:?}"); + assert!((read.heads[0].reach() - 4.0f64.hypot(2.0)).abs() < 1e-6); + let b = read.heads[1].path.bounds().unwrap(); + assert!((b.x0 + 5.0).abs() < 1e-3 && (b.x1 - 5.0).abs() < 1e-3 && (b.y0 + 2.0).abs() < 1e-3 && (b.y1 - 2.0).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn the_arrowheads_file_is_made_opened_and_reloaded_when_saved() { + let dir = folder("edit"); + let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string(); + let (mut s, id) = session_with_line(); + s.attach_arrowheads_file(file.clone()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert_eq!((list["arrowheads"].as_array().unwrap().len(), list["file"].as_str()), (0, Some(file.as_str()))); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Example Arrowhead"}})).is_err(), "no such head yet"); + let line_doc = s.doc().unwrap().uid; + let r = s.execute("stroke.arrowheads.edit", &json!({})).unwrap(); + assert_eq!(r["created"], true); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.as_str()), "the file is the active document"); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert_eq!(list["arrowheads"][0]["name"], "Example Arrowhead"); + // A second head drawn in the file is listed once the file is saved. + let st = s.doc_mut().unwrap(); + let d = Arc::make_mut(&mut st.doc); + add_symbol(d, "Square", polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled())); + assert_eq!(s.execute("stroke.arrowheads.list", &json!({})).unwrap()["arrowheads"].as_array().unwrap().len(), 1); + s.execute("document.save", &json!({})).unwrap(); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect(); + assert_eq!(names, ["Example Arrowhead", "Square"]); + // Editing again from the line's document brings the open file to the front, without making + // it anew or opening it twice. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().uid, line_doc); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false); + assert_eq!((s.documents().len(), s.doc().unwrap().path.as_deref()), (2, Some(file.as_str()))); + // The line takes the head with its art. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": {"custom": "Square"}})).unwrap(); + let Some(Arrowhead::Custom(c)) = stroke(&s, id).end_arrow else { panic!("a custom head") }; + assert_eq!(c.name, "Square"); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": 3}})).is_err()); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Circle"}})).is_err()); + // A head given with its art is refused when the art could not be drawn. + let mut far = serde_json::to_value(Arrowhead::Custom(c.clone())).unwrap(); + far["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e6, 1.0)], true))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": far})).is_err()); + let mut empty = serde_json::to_value(Arrowhead::Custom(c)).unwrap(); + empty["Custom"]["path"] = json!({"subpaths": []}); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": empty})).is_err()); + // One anchor, or anchors on one line, fill nothing. + for pts in [vec![Point::new(1.0, 1.0)], vec![Point::new(0.0, 0.0), Point::new(2.0, 0.0), Point::new(4.0, 0.0)]] { + let mut flat = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Flat".into(), PathData::default())))).unwrap(); + flat["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&pts, false))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": flat})).is_err(), "{pts:?}"); + } + // A lone anchor draws no segment, whatever its handles. + let mut lone = vectorcraft_geom::Anchor::corner(Point::new(1.0, 0.0)); + (lone.h_in, lone.h_out) = (Point::new(0.0, 1.0), Point::new(2.0, -1.0)); + let mut handles = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Lone".into(), PathData::default())))).unwrap(); + handles["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::new(vec![lone], true))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles.clone()})).is_err(), "a lone anchor"); + // Two equal squares winding opposite ways cancel out. + let sq = [Point::new(0.0, -1.0), Point::new(2.0, -1.0), Point::new(2.0, 1.0), Point::new(0.0, 1.0)]; + let back: Vec = sq.iter().rev().copied().collect(); + let cancel = PathData::new(vec![SubPath::polyline(&sq, true), SubPath::polyline(&back, true)]); + handles["Custom"]["path"] = serde_json::to_value(cancel).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles})).is_err(), "opposite squares"); + // A bow tie's lobes wind opposite ways, so its signed area is 0, and it still fills both. + let tie = [Point::new(0.0, -1.0), Point::new(2.0, 1.0), Point::new(2.0, -1.0), Point::new(0.0, 1.0)]; + let mut bow = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Bow".into(), PathData::default())))).unwrap(); + bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&tie, true))).unwrap(); + assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Square"), "the head stays as it was"); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow.clone()})).unwrap(); + assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Bow")); + // So does a very thin bar. + let bar = [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 0.001), Point::new(0.0, 0.001)]; + bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&bar, true))).unwrap(); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow})).unwrap(); + let Some(Arrowhead::Custom(h)) = stroke(&s, id).end_arrow else { panic!("a custom head") }; + assert_eq!(h.path.anchor_count(), 4, "the thin bar"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_document_keeps_its_custom_heads_without_the_arrowheads_file() { + let (mut s, id) = session_with_line(); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + let head = Arrowhead::Custom(Arc::new(heads_of(&d).heads.remove(0))); + // As document.inspect reports it, so it reads back unchanged. + s.execute("stroke.set", &json!({"startArrow": head, "endArrow": "Triangle"})).unwrap(); + assert_eq!(stroke(&s, id).start_arrow, Some(head.clone())); + let inspect = s.execute("document.inspect", &json!({})).unwrap().to_string(); + assert!(inspect.contains("\"Wedge\""), "inspect names the head"); + let ink = |s: &Session| { + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &Default::default()) + }; + let with_head = ink(&s); + // Saved and opened in a session that has no arrowheads file: the head is drawn the same. + let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap(); + assert_eq!(stroke(&o, id).start_arrow, Some(head)); + assert_eq!(ink(&o).pixels, with_head.pixels); + // SVG and PDF write it as they write the built-in heads. + let svg = |s: &mut Session| s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().len(); + let headed = svg(&mut o); + o.execute("stroke.set", &json!({"ids": [id.0], "startArrow": null})).unwrap(); + assert!(headed > svg(&mut o), "the head is in the SVG"); + // Undo puts it back. + o.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(stroke(&o, id).start_arrow, Some(Arrowhead::Custom(_)))); +} + +#[test] +fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() { + let dir = folder("bad"); + let file = dir.join(arrowheads::FILE_NAME); + std::fs::write(&file, b"not a drawing").unwrap(); + let mut s = Session::new(); + s.attach_arrowheads_file(file.to_string_lossy().to_string()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert!(list["arrowheads"].as_array().unwrap().is_empty()); + assert!(list["error"].is_string(), "{list}"); + assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err()); + // A folder in its place is not read either. + std::fs::remove_file(&file).unwrap(); + std::fs::create_dir(&file).unwrap(); + assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err()); + let _ = std::fs::remove_dir_all(&dir); + // Without a file (the web, runs without preferences) there is nothing to edit. + let mut bare = Session::new(); + assert!(bare.execute("stroke.arrowheads.edit", &json!({})).is_err()); + assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null); +} + +#[test] +fn the_heads_listed_after_a_save_are_read_from_the_file_and_editing_keeps_it() { + let dir = folder("saved"); + let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string(); + let (mut s, _) = session_with_line(); + s.attach_arrowheads_file(file.clone()); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], true); + // A speck that saving rounds to a point is a head only before the save. + let st = s.doc_mut().unwrap(); + add_symbol(Arc::make_mut(&mut st.doc), "Speck", polygon(&[(0.0, 0.0), (0.3, 0.1), (0.0, 0.2)], filled())); + assert!(heads_of(&s.doc().unwrap().doc).heads.iter().any(|h| h.name == "Speck"), "a head before rounding"); + s.execute("document.save", &json!({"precision": 0})).unwrap(); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect(); + assert_eq!(names, ["Example Arrowhead"]); + assert_eq!(list["skipped"][0]["name"], "Speck", "{list}"); + // Editing again leaves the file as it is. + let saved = std::fs::read(&file).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false); + assert_eq!(std::fs::read(&file).unwrap(), saved, "the file is left as it was"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_arrowheads_file_that_unpacks_past_the_size_limit_is_not_read() { + let dir = folder("unpacked"); + let file = dir.join(arrowheads::FILE_NAME); + let mut d = arrowheads::starter(); + d.title = "a".repeat(arrowheads::MAX_FILE_BYTES as usize); + let bytes = vectorcraft_format::save_with(&d, &vectorcraft_format::SaveOptions { compress: true, ..Default::default() }).unwrap(); + assert!((bytes.len() as u64) < arrowheads::MAX_FILE_BYTES / 16, "compressed small: {}", bytes.len()); + std::fs::write(&file, &bytes).unwrap(); + let mut s = Session::new(); + s.attach_arrowheads_file(file.to_string_lossy().to_string()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert!(list["arrowheads"].as_array().unwrap().is_empty(), "{}", list["arrowheads"]); + assert!(list["error"].is_string(), "{}", list["error"]); + // The same head in a file of an ordinary size is read. + std::fs::write(&file, vectorcraft_format::save_file(&arrowheads::starter())).unwrap(); + assert_eq!(s.execute("stroke.arrowheads.reload", &json!({})).unwrap()["count"], 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_symbol_whose_shapes_cannot_be_combined_is_skipped() { + let wedge = || Arc::new(polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + // Too far out for the shape operations to measure: the union would leave it out. + let far = || Arc::new(polygon(&[(0.0, 0.0), (1e200, 0.0), (0.0, 1e200)], filled())); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Wedge", (*wedge()).clone()); + add_symbol(&mut d, "Broken", Node::group(NodeId(0), vec![wedge(), far()])); + // The same as clipped art. + let mut mask = polygon(&[(0.0, -10.0), (20.0, -10.0), (20.0, 10.0), (0.0, 10.0)], Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut mask.kind { + *clipping = true; + } + add_symbol(&mut d, "Broken Clip", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), wedge(), far()], clip: true })); + let read = heads_of(&d); + let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect(); + assert_eq!(names, ["Wedge"]); + let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(skipped, ["Broken", "Broken Clip"], "{:?}", read.skipped); +} + +#[test] +fn a_group_used_as_the_clipping_path_adds_none_of_its_own_fills() { + // A group holding a filled 20 × 4 bar clips a filled 4 × 4 square: the group paints nothing as + // the clipping path, so the head is the square alone. + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + let mask = Node::group(NodeId(0), vec![Arc::new(bar)]); + let square = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled()); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Group Clip", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(square)], clip: true })); + let read = heads_of(&d); + assert_eq!(read.heads.len(), 1, "{:?}", read.skipped); + let b = read.heads[0].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the square alone: {b:?}"); +} diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 1893f8c186..0d9188243c 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -375,3 +375,72 @@ fn the_selection_tool_reports_a_transforming_drag() { assert!(s.tool_busy() && !s.tool_transforming()); s.pointer(&ev(PointerKind::Up, 550.0, 450.0), v).unwrap(); } + +/// With General › Show Bounding Box While Moving and Drawing on, a move drag's bounding box is the +/// box measured once, at the drag's start, offset by each step's move: with Real-time Drawing on +/// and off, the later steps do not measure the selection again. Off, only the Selection tool with +/// Real-time Drawing off gives a box (the art waits for the release). +#[test] +fn a_move_drag_measures_the_bounding_box_once() { + for (real_time, while_moving) in [(true, true), (false, true), (true, false), (false, false)] { + let what = format!("real time {real_time}, box while moving {while_moving}"); + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + sel(&mut s, &[id]); + s.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap(); + s.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": while_moving})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y).with_mods(Mods::default()); + s.pointer(&ev(PointerKind::Down, 150.0, 125.0), v).unwrap(); + assert_eq!(s.move_drag_box(), None, "{what}: not before the drag begins"); + s.pointer(&ev(PointerKind::Drag, 170.0, 135.0), v).unwrap(); + if real_time && !while_moving { + assert_eq!(s.move_drag_box(), None, "{what}: no box during the move"); + s.pointer(&ev(PointerKind::Up, 170.0, 135.0), v).unwrap(); + continue; + } + assert_eq!(s.move_drag_box(), Some(OrientedBox::aligned(Rect::new(120.0, 110.0, 220.0, 160.0))), "{what}"); + // The measurement made at the drag's start is the one each step offsets: replaced by a + // stand-in, the next step's box is the stand-in moved. + let it = s.doc_mut().unwrap().interaction.as_mut().unwrap(); + assert_eq!(it.move_box.get(), Some(&Some(OrientedBox::aligned(Rect::new(100.0, 100.0, 200.0, 150.0)))), "{what}: measured at the start"); + it.move_box = std::sync::OnceLock::from(Some(OrientedBox::aligned(Rect::new(0.0, 0.0, 10.0, 10.0)))); + s.pointer(&ev(PointerKind::Drag, 200.0, 165.0), v).unwrap(); + assert_eq!(s.move_drag_box(), Some(OrientedBox::aligned(Rect::new(50.0, 40.0, 60.0, 50.0))), "{what}: not measured again"); + s.pointer(&ev(PointerKind::Up, 200.0, 165.0), v).unwrap(); + assert_eq!(s.move_drag_box(), None, "{what}: none after the release"); + assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(150.0, 140.0, 250.0, 190.0)), "{what}: moved"); + } +} + +/// A shape being drawn has no preview applied after a step that failed (with Real-time Drawing on, +/// also when the failed command left a document other than the interaction's snapshot), and with +/// Real-time Drawing off, where the document stays at the snapshot. A successful step with Real-time +/// Drawing on applies it. +#[test] +fn a_shape_being_drawn_reports_whether_a_preview_is_applied() { + for real_time in [true, false] { + let what = format!("real time {real_time}"); + let mut s = session(); + s.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("rectangle", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 140.0), v).unwrap(); + assert_eq!(s.interaction_unapplied(), !real_time, "{what}: after a step that succeeded"); + assert!(s.preview("shape.rectangle", &json!({"x": "left"})).is_err(), "{what}: the preview fails"); + assert!(s.interaction_unapplied(), "{what}: after a step that failed"); + if real_time { + // A failed command that copied the document on write leaves another document than the + // snapshot; no preview is applied all the same. + let st = s.doc_mut().unwrap(); + st.doc = std::sync::Arc::new((*st.doc).clone()); + assert!(s.interaction_unapplied(), "{what}: a failed step with a copied document"); + } + s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 150.0), v).unwrap(); + assert_eq!(s.interaction_unapplied(), !real_time, "{what}: the next step succeeded"); + s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 150.0), v).unwrap(); + assert!(!s.interaction_unapplied(), "{what}: no interaction after the release"); + } +} diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 4b391c5714..93f531d1e8 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -405,6 +405,143 @@ fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse( } } +/// `object.blend.setEasing` sets one blend's easing and color shift, records no undo step when +/// nothing changes, and refuses bad values, a blend whose steps follow no spine (easing only), a +/// hidden or locked blend and a target that is not a blend, leaving the blend and the history alone. +#[test] +fn set_easing_edits_one_blend_and_refuses_what_it_cannot_set() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap()); + let depth = |s: &Session| s.doc().unwrap().history.undo.len(); + let d0 = depth(&s); + // Align to Page along the straight lines between the keys: the steps follow no spine. + for bad in [json!({"ramp": 30}), json!({"easingType": "easeOut"})] { + let e = s.execute("object.blend.setEasing", &bad).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{e}"); + } + assert_eq!(depth(&s), d0); + let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap(); + assert_eq!(r, json!({"id": g.0, "easingType": "easeIn", "ramp": 0.0, "colorShift": 25.0, "changed": true})); + assert_eq!((spec(&s, g).1.color_shift, depth(&s)), (25.0, d0 + 1)); + let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap(); + assert_eq!((&r["changed"], depth(&s)), (&json!(false), d0 + 1), "an unchanged value records no undo step"); + // Along the spine each of the four types keeps its kind through a ramp change and its undo. + s.execute("object.blend.options", &json!({"orientation": "path"})).unwrap(); + for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] { + s.execute("object.blend.setEasing", &json!({"easingType": kind, "ramp": 60})).unwrap(); + let r = s.execute("object.blend.setEasing", &json!({"ramp": 20})).unwrap(); + assert_eq!((&r["easingType"], &r["ramp"], &r["colorShift"]), (&json!(kind), &json!(20.0), &json!(25.0))); + s.execute("edit.undo", &json!({})).unwrap(); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift), (kind, 0.6, 25.0)); + } + let before = spec(&s, g).1; + let d1 = depth(&s); + for bad in [ + json!({"ramp": 101}), + json!({"ramp": -1}), + json!({"ramp": "x"}), + json!({"colorShift": 1e308}), + json!({"colorShift": -101}), + json!({"easingType": "sideways"}), + json!({"id": "abc", "ramp": 10}), + json!({"id": a.0, "ramp": 10}), + ] { + assert!(s.execute("object.blend.setEasing", &bad).is_err(), "{bad}"); + assert_eq!((spec(&s, g).1, depth(&s)), (before.clone(), d1), "{bad}"); + } + // An explicit id works with nothing selected; nothing selected and no id is an error. + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("object.blend.setEasing", &json!({"ramp": 10})).is_err()); + s.execute("object.blend.setEasing", &json!({"id": g.0, "ramp": 10})).unwrap(); + assert_eq!(spec(&s, g).1.ease.ramp, 0.1); + // A hidden or locked blend is refused. + for hide in [true, false] { + s.edit("state", |d, _| { + let n = d.node_mut(g).unwrap(); + (n.visible, n.locked) = (!hide, !hide); + Ok(()) + }) + .unwrap(); + let (before, d2) = (spec(&s, g).1, depth(&s)); + let e = s.execute("object.blend.setEasing", &json!({"id": g.0, "colorShift": 5})).unwrap_err().to_string(); + assert!(e.contains("the object is hidden or locked"), "{e}"); + assert_eq!((spec(&s, g).1, depth(&s)), (before, d2)); + } + // A blend made without a spine takes no easing, Align to Path included; its color shift still + // changes. + let (c, d) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0)); + sel(&mut s, &[c, d]); + let n = id_of(&s.execute("object.blend.make", &json!({"createSpine": "never", "orientation": "path"})).unwrap()); + let d3 = depth(&s); + let e = s.execute("object.blend.setEasing", &json!({"id": n.0, "ramp": 30})).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{e}"); + assert_eq!(depth(&s), d3); + s.execute("object.blend.setEasing", &json!({"id": n.0, "colorShift": 10})).unwrap(); + assert_eq!(spec(&s, n).1.color_shift, 10.0); + // Replace Spine gives it a spine, and it takes easing from then on. + let path = id_of(&s.execute("path.create", &json!({"d": "M0 320 L200 320"})).unwrap()); + sel(&mut s, &[n, path]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let r = s.execute("object.blend.setEasing", &json!({"id": n.0, "easingType": "easeOut", "ramp": 30})).unwrap(); + assert_eq!((&r["easingType"], &r["ramp"], &r["changed"]), (&json!("easeOut"), &json!(30.0), &json!(true))); +} + +/// Dragging a blend widget with the Selection tool or the Blend tool is one undo step; a click, a +/// drag that ends at its starting value and a drag canceled with Escape record none, and Escape +/// puts the value back. +#[test] +fn dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "orientation": "path", "easingType": "easeOut"})).unwrap()); + let v = ViewInfo::default(); + let depth = |s: &Session| s.doc().unwrap().history.undo.len(); + let ptr = |s: &mut Session, kind: PointerKind, x: f64, y: f64| { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); + }; + s.select_tool("selection", v).unwrap(); + sel(&mut s, &[g]); + let d0 = depth(&s); + // The Easing widget sits 16 px below the spine's halfway point (120, 20). + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 135.0, 36.0); + ptr(&mut s, PointerKind::Drag, 150.0, 36.0); + ptr(&mut s, PointerKind::Up, 150.0, 36.0); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, 0.0, d0 + 1)); + assert_eq!(s.doc().unwrap().selection.objects, vec![g], "the blend stays selected"); + // A click, and a drag back to where it started. + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Up, 120.0, 36.0); + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 160.0, 36.0); + ptr(&mut s, PointerKind::Drag, 120.0, 36.0); + ptr(&mut s, PointerKind::Up, 120.0, 36.0); + assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1)); + // Escape during a drag puts the value back. + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 170.0, 36.0); + assert_eq!(spec(&s, g).1.ease.ramp, 0.8, "the drag previews"); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + ptr(&mut s, PointerKind::Up, 170.0, 36.0); + assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1)); + // The Blend tool drags the Appearance widget, 16 px above: the color shift alone. + s.select_tool("blend", v).unwrap(); + sel(&mut s, &[g]); + ptr(&mut s, PointerKind::Down, 120.0, 4.0); + ptr(&mut s, PointerKind::Drag, 100.0, 4.0); + ptr(&mut s, PointerKind::Up, 100.0, 4.0); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, -20.0, d0 + 2)); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, g).1.color_shift, 0.0); +} + #[test] fn native_blend_easing_values_clamp_and_non_finite_become_default() { for (bad, ramp, shift) in [ diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 9a3a18ed91..2d363d18cf 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -106,7 +106,17 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); assert_eq!( ids, - ["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams", "dry-media"] + [ + "art-ribbons", + "ink-and-wash", + "calligraphic-nibs", + "bristle-brushes", + "pattern-borders", + "scatter-shapes", + "game-trails", + "energy-beams", + "dry-media" + ] ); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); @@ -120,26 +130,28 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] ); - assert_eq!(libs[5]["count"].as_u64(), Some(4)); + assert_eq!(libs[6]["count"].as_u64(), Some(4)); let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"})); assert_eq!( beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); - assert_eq!(libs[6]["count"].as_u64(), Some(4)); + assert_eq!(libs[7]["count"].as_u64(), Some(4)); let dry = run(&mut s, "brush.library", json!({"library": "Dry Media"})); assert_eq!( dry["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect::>(), [("Chalk Grain", "art"), ("Graphite Pencil", "art"), ("Wax Crayon", "art"), ("Charcoal Dust", "art")] ); - assert_eq!((libs[7]["name"].as_str(), libs[7]["count"].as_u64()), (Some("Dry Media"), Some(4))); + assert_eq!((libs[8]["name"].as_str(), libs[8]["count"].as_u64()), (Some("Dry Media"), Some(4))); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); assert_eq!(strs(&added["added"]), ["Laser Bolt"]); - // One kind of brush per library, every brush drawing something. + // One kind of brush per library (Ink and Wash: art brushes and a calligraphic one), every + // brush drawing something. for (id, kind) in [ ("art-ribbons", "art"), + ("ink-and-wash", "art"), ("calligraphic-nibs", "calligraphic"), ("bristle-brushes", "bristle"), ("pattern-borders", "pattern"), @@ -151,6 +163,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); for b in &lib.brushes { + let kind = if b.name == "Brush Pen" { "calligraphic" } else { kind }; assert_eq!(b.kind.type_id(), kind, "{}", b.name); let mut line = vectorcraft_geom::BezPath::new(); line.move_to((0.0, 0.0)); @@ -176,6 +189,153 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// Every path of brush art `n`: (anchors, opacity). +fn art_paths(n: &vectorcraft_doc::Node) -> Vec<(usize, f32)> { + let mut out = vec![]; + n.walk(&mut |c| { + if let Some(p) = c.path_data() { + out.push((p.anchor_count(), c.opacity)); + } + }); + out +} + +/// The Ink and Wash library, made once. +fn inks(s: &Session) -> std::sync::Arc { + cmd::brushlib::library(s, "ink-and-wash").unwrap().1 +} + +/// A straight line from the origin to (`x`, 0). +fn line_to(x: f64) -> vectorcraft_geom::BezPath { + let mut line = vectorcraft_geom::BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((x, 0.0)); + line +} + +fn black_stroke() -> vectorcraft_doc::StrokeLayer { + vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0) +} + +/// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two +/// translucent washes, with names of their own and a few paths of art each. +#[test] +fn the_ink_and_wash_library_has_tapered_ink_a_brush_pen_and_two_washes() { + let mut s = session(); + let lib = run(&mut s, "brush.library", json!({"library": "ink-and-wash"})); + assert_eq!((lib["name"].as_str(), lib["category"].as_str()), (Some("Ink and Wash"), Some("builtIn"))); + let listed: Vec<(&str, &str)> = + lib["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect(); + assert_eq!(listed, [("Tapered Ink", "art"), ("Brush Pen", "calligraphic"), ("Soft Wash", "art"), ("Pooling Wash", "art")]); + assert_eq!(run(&mut s, "brush.library", json!({"library": "Ink and Wash"}))["id"], "ink-and-wash"); + let inks = inks(&s); + // Names no other built-in library uses. + for b in cmd::brushlib::BUILTIN.iter().filter(|b| b.id != "ink-and-wash") { + let (_, other) = cmd::brushlib::library(&s, b.id).unwrap(); + assert!(inks.brushes.iter().all(|x| other.brush(&x.name).is_none()), "{}", b.id); + } + // The art: within its 100 pt length, a few paths each, opaque ink and translucent washes. + let mut anchors = 0; + for name in ["Tapered Ink", "Soft Wash", "Pooling Wash"] { + let BrushKind::Art(a) = &inks.brush(name).unwrap().kind else { panic!("{name} is not an art brush") }; + assert_eq!((a.scale, a.colorization), (vectorcraft_brush::ArtScale::Stretch, vectorcraft_brush::Colorization::Tints), "{name}"); + let b = a.art.geometric_bounds().unwrap(); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{name}"); + assert!(b.x0 >= 0.0 && b.x1 <= 100.0 && b.x1 - b.x0 > 99.0, "{name}: {b:?}"); + assert!(b.y0 >= -8.0 && b.y1 <= 8.0 && b.y1 - b.y0 > 5.0, "{name}: {b:?}"); + let paths = art_paths(&a.art); + let count: usize = paths.iter().map(|p| p.0).sum(); + assert!(paths.len() <= 80 && count <= 500, "{name}: {} paths, {count} anchors", paths.len()); + anchors += count; + let opacities: Vec = paths.iter().map(|p| p.1).collect(); + if name == "Tapered Ink" { + assert_eq!(opacities, [1.0], "opaque ink"); + } else { + assert!(paths.len() >= 3, "{name}: a wash of several layers"); + assert!(opacities.iter().all(|o| (0.15..=0.55).contains(o)), "{name}: {opacities:?}"); + } + } + assert!(anchors <= 1200, "{anchors} anchors"); + let BrushKind::Calligraphic(pen) = &inks.brush("Brush Pen").unwrap().kind else { panic!("Brush Pen is not calligraphic") }; + use vectorcraft_brush::Variation::{Fixed, Pressure}; + assert_eq!((pen.size, pen.modes(), pen.variation), (5.0, [Fixed, Fixed, Pressure], [0.0, 0.0, 2.5]), "no random variation"); +} + +/// Every Ink and Wash brush draws along straight, curved, short and tight paths within finite +/// bounds, and Tapered Ink is broad near its start and fine at its end. +#[test] +fn ink_and_wash_brushes_draw_along_straight_curved_short_and_tight_paths() { + let inks = inks(&Session::new()); + let mut curve = vectorcraft_geom::BezPath::new(); + curve.move_to((0.0, 0.0)); + curve.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0)); + let tight = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 6.0, 6.0)).to_bezpath(); + let paths = [line_to(200.0), line_to(3.0), curve, tight]; + for b in &inks.brushes { + for line in &paths { + let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, b, line, &black_stroke()); + assert!(!pieces.is_empty(), "{} draws nothing", b.name); + let bounds: Vec<_> = pieces.iter().map(|p| p.geometric_bounds()).collect(); + assert!(bounds.iter().all(|r| r.is_some_and(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()))), "{}", b.name); + } + } + let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, inks.brush("Tapered Ink").unwrap(), &line_to(100.0), &black_stroke()); + let widest = |from: f64, to: f64| { + pieces + .iter() + .filter_map(|n| n.path_data()) + .flat_map(|p| p.anchors().map(|(_, _, a)| a.p).collect::>()) + .filter(|p| p.x >= from && p.x <= to) + .map(|p| p.y.abs()) + .fold(0.0, f64::max) + }; + assert!(widest(15.0, 30.0) > 4.0 * widest(95.0, 100.0), "broad start, fine tip"); +} + +/// Ink and Wash brushes are added and applied in one step, undone and redone, not added twice, +/// and kept with the brushed stroke by a saved and reopened document. +#[test] +fn ink_and_wash_brushes_add_apply_undo_and_survive_a_saved_document() { + let mut s = session(); + let inks = inks(&s); + let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap(); + let (defaults, before) = (brushes(&s), undo_len(&s)); + let names = ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"]; + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash", "name": "Soft Wash", "apply": true, "ids": [path]})); + assert_eq!((strs(&r["added"]), r["applied"].clone(), undo_len(&s)), (vec!["Soft Wash"], json!([path]), before + 1)); + let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone()); + assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash")); + run(&mut s, "edit.undo", json!({})); + assert_eq!((brushes(&s), stroke_brush(&s)), (defaults.clone(), None)); + run(&mut s, "edit.redo", json!({})); + assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash")); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"})); + assert_eq!( + (strs(&r["names"]), strs(&r["existing"]), strs(&r["added"])), + (names.to_vec(), vec!["Soft Wash"], vec!["Tapered Ink", "Brush Pen", "Pooling Wash"]) + ); + let before = undo_len(&s); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"})); + assert_eq!((strs(&r["existing"]), undo_len(&s)), (names.to_vec(), before)); + assert_eq!(brushes(&s).len(), defaults.len() + 4); + for n in names { + assert_eq!(&brush(&s, n), inks.brush(n).unwrap(), "{n}"); + } + + // Saved and opened again, the document keeps the definitions and the brushed stroke. + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + let reopened = vectorcraft_format::load(&vectorcraft_format::base64_decode(&b64).unwrap()).unwrap(); + for n in names { + assert_eq!(vectorcraft_brush::find(&reopened, n).as_ref(), inks.brush(n), "{n}"); + } + let n = reopened.node(NodeId(path)).unwrap(); + assert_eq!(n.appearance.stroke().and_then(|st| st.brush.clone()).as_deref(), Some("Soft Wash")); + assert_eq!( + vectorcraft_brush::node_pieces(&reopened, n).map(|p| p.len()), + vectorcraft_brush::node_pieces(doc(&s), doc(&s).node(NodeId(path)).unwrap()).map(|p| p.len()) + ); +} + /// Whether `p` lies inside the polygon through `pts` (even-odd ray cast). fn inside(p: vectorcraft_geom::Point, pts: &[vectorcraft_geom::Point]) -> bool { let mut odd = false; diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 0fc2bcb476..49a806b258 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -793,8 +793,9 @@ fn shape_builder_applies_a_merge_before_what_comes_next() { let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect(); assert_eq!(labels, ["Move", "Shape Builder"]); }), - // The proof views redraw every document (a new revision); they are left off, as the - // other tests of the process-wide proof view expect. + // The proof views apply the merge first by command id (`WAIT_FOR_TOOL_EDIT`), also when + // the view does not change and no document gets a new revision. They are left off, as + // the other tests of the process-wide proof view expect. ("Separations Preview", |s, _, _| { s.execute("view.separationsPreview", &json!({"on": false})).unwrap(); }), @@ -807,6 +808,13 @@ fn shape_builder_applies_a_merge_before_what_comes_next() { ("Proof Setup", |s, _, _| { s.execute("view.proofSetup", &json!({})).unwrap(); }), + ("a proof view that changes", |s, _, _| { + let _v = crate::tests_colormgmt::DefaultView::new(); + s.execute("view.proofColors", &json!({"on": true})).unwrap(); + let on = s.doc().unwrap().revision; + s.execute("view.proofColors", &json!({"on": false})).unwrap(); + assert!(s.doc().unwrap().revision > on, "the view changes and the documents get new revisions"); + }), ("a preference", |s, _, _| { let every = s.prefs.gridline_every; s.execute("prefs.set", &json!({"key": "gridlineEvery", "value": every + 10.0})).unwrap(); diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index d0901f30a0..493f569805 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -308,6 +308,23 @@ fn assign_profile_is_stored_and_undoable() { assert_eq!(cmd::colormgmt::doc_profiles(&s.doc().unwrap().doc), (None, None)); } +/// Assigning the working profiles leaves the active color settings and the other documents' +/// revisions unchanged. +#[test] +fn assigning_the_working_profiles_keeps_the_active_color_settings() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let working = cms::active_settings(); + let active = cms::active(); + let other = s.documents()[0].revision; + let r = s.execute("edit.assignProfile", &json!({"rgb": working.rgb, "cmyk": working.cmyk})).unwrap(); + assert_eq!((r["rgb"].as_str(), r["cmyk"].as_str()), (Some(working.rgb.as_str()), Some(working.cmyk.as_str()))); + assert_eq!(cmd::colormgmt::doc_profiles(&s.doc().unwrap().doc), (Some(working.rgb.clone()), Some(working.cmyk.clone()))); + assert!(std::sync::Arc::ptr_eq(&active, &cms::active()), "the active color settings were replaced"); + assert_eq!(s.documents()[0].revision, other, "the other document's revision changed"); +} + #[test] fn proof_colors_simulate_press_on_screen() { let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); @@ -438,6 +455,7 @@ fn overprint_black_multiplies_in_preview() { #[test] fn view_toggles_redraw_without_dirtying() { let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); let mut s = session(); let rev = s.doc().unwrap().revision; let r = s.execute("view.proofColors", &json!({"on": true})).unwrap(); @@ -458,6 +476,101 @@ fn view_toggles_redraw_without_dirtying() { s.execute("view.overprintPreview", &json!({"on": false})).unwrap(); } +/// Sets the default proof view when made and again when dropped, also after a failed assertion, +/// so the next test starts from the default view. +pub(crate) struct DefaultView; + +impl DefaultView { + pub(crate) fn new() -> Self { + vectorcraft_render::proof::set_view(Default::default()); + DefaultView + } +} + +impl Drop for DefaultView { + fn drop(&mut self) { + vectorcraft_render::proof::set_view(Default::default()); + } +} + +/// Runs `cmd` with `p` twice from the default proof view: the first run changes the view and +/// redraws, and the second asks for the view that is already showing and leaves the revision alone. +fn repeat_keeps_revision(cmd: &str, p: Value) { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let rev = s.doc().unwrap().revision; + s.execute(cmd, &p).unwrap(); + let changed = s.doc().unwrap().revision; + assert!(changed > rev, "{cmd} {p}: the first run changes the view"); + s.execute(cmd, &p).unwrap(); + assert_eq!(s.doc().unwrap().revision, changed, "{cmd} {p}: the same view again"); +} + +/// Runs `cmd` with `p` once from the default proof view, which `p` leaves as it is. +fn default_view_keeps_revision(cmd: &str, p: Value) { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let rev = s.doc().unwrap().revision; + s.execute(cmd, &p).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev, "{cmd} {p}"); + assert_eq!(vectorcraft_render::proof::view(), Default::default(), "{cmd} {p}"); +} + +#[test] +fn proof_setup_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.proofSetup", json!({"target": "deuteranopia"})); + repeat_keeps_revision("view.proofSetup", json!({"simulatePaper": true, "proof": true})); + default_view_keeps_revision("view.proofSetup", json!({})); + default_view_keeps_revision("view.proofSetup", json!({"target": "workingCmyk", "proof": false})); +} + +#[test] +fn proof_colors_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.proofColors", json!({"on": true})); + default_view_keeps_revision("view.proofColors", json!({"on": false})); +} + +#[test] +fn overprint_preview_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.overprintPreview", json!({"on": true})); + default_view_keeps_revision("view.overprintPreview", json!({"on": false})); +} + +#[test] +fn separations_preview_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.separationsPreview", json!({"on": true})); + repeat_keeps_revision("view.separationsPreview", json!({"only": "Cyan"})); + repeat_keeps_revision("view.separationsPreview", json!({"plates": ["Magenta", "Black"]})); + default_view_keeps_revision("view.separationsPreview", json!({"on": false})); +} + +/// Separations Preview's visible plates are a set: the same plates in another order, or with one +/// named twice, leave the revision alone, and a plate named twice hides with one `toggle`. +#[test] +fn separations_preview_compares_the_visible_plates_as_a_set() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let sep = |s: &mut Session, p: Value| { + s.execute("view.separationsPreview", &p).unwrap(); + s.doc().unwrap().revision + }; + let shown = sep(&mut s, json!({"plates": ["Magenta", "Black"]})); + assert_eq!(sep(&mut s, json!({"plates": ["Black", "Magenta"]})), shown, "another order"); + assert_eq!(sep(&mut s, json!({"plates": ["Magenta", "Black", "Black"]})), shown, "a plate named twice"); + // Cyan hidden and shown again goes to the end of the list; every plate is shown. + sep(&mut s, json!({"on": false})); + sep(&mut s, json!({"on": true})); + sep(&mut s, json!({"toggle": "Cyan"})); + let all = sep(&mut s, json!({"toggle": "Cyan"})); + assert_eq!(sep(&mut s, json!({"plates": ["Cyan", "Magenta", "Yellow", "Black"]})), all, "every plate in plate order"); + sep(&mut s, json!({"plates": ["Cyan", "Cyan"]})); + let r = s.execute("view.separationsPreview", &json!({"toggle": "Cyan"})).unwrap(); + assert!(r["plates"].as_array().unwrap().iter().all(|p| p["visible"] == false), "{r}"); +} + #[test] fn proof_setup_simulates_tritanopia() { let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/engine/src/tests/css.rs b/crates/engine/src/tests/css.rs index 764990b100..1ce5b09e41 100644 --- a/crates/engine/src/tests/css.rs +++ b/crates/engine/src/tests/css.rs @@ -98,6 +98,69 @@ fn export_writes_the_sheet_and_rasterized_art() { assert!(s.execute("css.export", &json!({})).is_err(), "nothing selected: nothing to export"); } +#[test] +fn export_refuses_a_scope_or_path_that_is_not_a_string() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})); + run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40})); + let refused = |s: &mut Session, p: Value, key: &str| { + let e = s.execute("css.export", &p).expect_err(&p.to_string()).to_string(); + assert_eq!(e, format!("invalid parameters for `css.export`: `{key}` must be a string"), "{p}"); + }; + for scope in [json!(5), json!(true), json!(["all"]), Value::Null] { + refused(&mut s, json!({"scope": scope}), "scope"); + } + for path in [json!(5), Value::Null] { + refused(&mut s, json!({"path": path}), "path"); + refused(&mut s, json!({"path": path, "scope": "all"}), "path"); + } + // Left out, each keeps its default: the selection (the ellipse), and the CSS as data. + let r = run(&mut s, "css.export", json!({})); + assert_eq!((r["rules"].clone(), r["data"].is_string()), (json!(1), true), "{r}"); + assert_eq!(run(&mut s, "css.export", json!({"scope": "all"}))["rules"], json!(2)); + let e = s.execute("css.export", &json!({"scope": "page"})).unwrap_err().to_string(); + assert!(e.contains("scope must be selection or all (got page)"), "{e}"); +} + +#[test] +fn every_object_refuses_malformed_ids_it_does_not_use() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))["id"].as_u64().unwrap(); + run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40})); + for (cmd, base) in [("css.generate", json!({})), ("css.export", json!({"scope": "all"}))] { + for (key, value, says) in [ + ("ids", json!("abc"), "`ids` must be an array of object ids, not \"abc\""), + ("ids", json!([1.5]), "object ids are non-negative integers, not 1.5"), + ("id", json!(-1), "object ids are non-negative integers, not -1"), + ("id", json!("$step"), "`id` is the step reference \"$step\""), + ] { + let mut p = base.clone(); + p[key] = value; + let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")).to_string(); + assert!(e.contains(says), "{cmd} {p}: {e}"); + } + // Well-formed ids are still not used: every object. + let mut p = base.clone(); + p["ids"] = json!([rect]); + let rules = run(&mut s, cmd, p)["rules"].clone(); + let count = rules.as_array().map_or(rules.as_u64(), |r| Some(r.len() as u64)); + assert_eq!(count, Some(2), "{cmd}: {rules}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_with_a_scope_that_is_not_a_string_writes_nothing() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})); + let dir = std::env::temp_dir().join(format!("vc-css-scope-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("site.css"); + assert!(s.execute("css.export", &json!({"path": path.to_string_lossy(), "scope": 5})).is_err()); + assert!(!path.exists(), "no file for a refused scope"); + std::fs::remove_dir_all(&dir).ok(); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn export_to_a_path_puts_the_pictures_next_to_the_sheet() { diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs index 30a8776b35..5ce4a3757e 100644 --- a/crates/engine/src/tests/library_roots.rs +++ b/crates/engine/src/tests/library_roots.rs @@ -214,26 +214,72 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() { let mine = format!("loaded/{}", text(&inside)); let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine}))); assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}"); - assert_eq!(copied["path"], Value::Null, "{copied}"); + assert_eq!(copied.get("path"), None, "{copied}"); let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"}))); assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}"); for reply in [&added, &copied, &saved] { assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}"); } - // A library loaded from data under the file name keeps its id; each concealed one takes the next - // free one, so no two rows share an id. + // A library loaded from data under the file name keeps its id; the concealed one shown under that + // id takes the next free one, the other keeps its id, and no two rows share an id. let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string(); run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"})); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches")], "{list}"); // Copied into the user library folder, it is still found by that id, which no other library takes. confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"}))); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)")], "{list}"); // The person sees the folder. let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"})); assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches")))); let _ = std::fs::remove_dir_all(base); } + +/// Outside Windows a `\` is part of a file name: a concealed library saved at `/weird\name.gpl` +/// is shown under its whole file name. Another platform's absolute path is still named by the part +/// after its last `\`. +#[cfg(not(windows))] +#[test] +fn a_concealed_library_keeps_a_backslash_in_its_file_name() { + let base = folder("backslash-name"); + let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a"))); + let foreign = r"loaded/C:\Users\someone\other.gpl".to_string(); + let s = Session::new(); + let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()])); + let _ = std::fs::remove_dir_all(base); + hidden.sort(); + let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())]; + want.sort(); + assert_eq!(hidden, want); +} + +/// A concealed library keeps the id a client was shown when another library with the same file name, +/// whose full id sorts before it, is loaded later: the id still finds the first library. +#[test] +fn a_concealed_library_keeps_its_id_after_another_with_its_file_name_loads() { + let base = folder("keeps-id"); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); + let (first, later) = (base.join("b").join("Kit.vcswatches"), base.join("a").join("Kit.vcswatches")); + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + (SWATCHES.add)(&mut s, "First"); + run(&mut s, "swatch.library.save", json!({"path": text(&first), "name": "Kit"})); + (SWATCHES.add)(&mut s, "Second"); + run(&mut s, "swatch.library.save", json!({"path": text(&later), "name": "Kit"})); + let shown = "loaded/Kit.vcswatches"; + let count = run(&mut s, "swatch.library.load", json!({"path": text(&first)}))["count"].clone(); + let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); + assert_eq!(loaded(&list), [(json!(shown), Value::Null, count)], "{list}"); + run(&mut s, "swatch.library.load", json!({"path": text(&later)})); + let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); + let ids: Vec = loaded(&list).into_iter().map(|(id, ..)| id).collect(); + assert_eq!(ids, [json!(shown), json!("loaded/Kit.vcswatches (2)")], "{list}"); + let theirs = run(&mut s, "swatch.library.get", json!({"library": format!("loaded/{}", text(&first))})); + let got = confine(roots.as_ref(), || run(&mut s, "swatch.library.get", json!({"library": shown}))); + assert_eq!((&got["id"], &got["swatches"]), (&json!(shown), &theirs["swatches"]), "the first library: {got}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index f0a0674f08..a3c7a72aee 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -728,6 +728,56 @@ fn relink_each_with_copy_copies_nothing_when_a_file_cant_be_read() { assert_eq!(link_path(&s, a), one); } +/// With copy, for a client whose read roots don't hold the document's folder: the reply names the +/// copy by its file name, and a copy refused there keeps the write root and names the folder and +/// the file by their names. +#[test] +fn relink_with_copy_names_the_documents_folder_by_its_name_outside_the_read_roots() { + use crate::file_access::{AutomationRoots, confine}; + + let dir = Folder::new("relink-copy-roots"); + let one = dir.file("doc/one.png"); + write(&one, &png(60, 30, RED)); + let mut s = session(); + let a = place(&mut s, &one); + save(&mut s, &dir.file("doc/poster.vectorcraft")); + let x = dir.file("new/x.png"); + write(&x, &png(60, 30, BLUE)); + let (new, doc) = (Path::new(&x).parent().unwrap().to_path_buf(), Path::new(&one).parent().unwrap().to_path_buf()); + let hidden = doc.to_string_lossy().into_owned(); + let p = json!({"ids": [a.0], "path": x, "copy": true}); + + let writes_there = AutomationRoots::new(&[new.as_path()], &[doc.as_path()]).unwrap(); + let r = confine(writes_there.as_ref(), || s.execute("links.relink", &p)).unwrap(); + assert_eq!(r["copied"], json!([{"from": x, "to": "x.png"}]), "{r}"); + assert!(!r.to_string().contains(&hidden), "{r}"); + std::fs::remove_file(dir.file("doc/x.png")).unwrap(); + + let elsewhere = AutomationRoots::new(&[new.as_path()], &[new.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&new).unwrap()).display().to_string(); + let e = confine(elsewhere.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string(); + assert_eq!(e, format!("doc: automation path rejected: outside the write root {root}: x.png")); + assert!(!Path::new(&dir.file("doc/x.png")).exists()); +} + +/// With copy, for a document whose path has no folder: outside the read roots the error does not +/// name the path, and without roots it names it whole. +#[test] +fn relink_with_copy_names_a_document_path_without_a_folder_as_the_request_may_see_it() { + use crate::file_access::{AutomationRoots, confine}; + + let dir = Folder::new("relink-copy-no-folder"); + let mut s = session(); + s.doc_mut().unwrap().path = Some("/".into()); + let p = json!({"path": dir.file("x.png"), "copy": true}); + let roots = AutomationRoots::new(&[dir.0.as_path()], &[dir.0.as_path()]).unwrap(); + let e = confine(roots.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string(); + assert_eq!(e, "invalid parameters for `links.relink`: a file or folder outside the read roots has no folder"); + let whole = s.execute("links.relink", &p).unwrap_err().to_string(); + let doc = crate::cmd::fileio::absolute_path("/"); + assert_eq!(whole, format!("invalid parameters for `links.relink`: {doc} has no folder")); +} + /// A scope or paths that doesn't fit is refused, and nothing is relinked. #[test] fn relink_refuses_scopes_and_paths_that_dont_fit() { diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 3847b5528f..247a037e45 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -291,6 +291,20 @@ fn file_new_and_artboard_new_take_the_same_largest_side() { assert!(err.contains(in_meters), "{p}: {err}"); } assert_eq!(boards(&s).len(), 2); + + // A side below 1 pt is kept as given, as file.new keeps it, and a size taken from such an artboard keeps it too. + let mut s = new_doc(json!({"width": 0.5, "height": 0.25})); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 0.25)]); + s.execute("artboard.new", &json!({"x": 0, "y": 10, "width": 0.5, "height": 0.75})).unwrap(); + s.execute("artboard.new", &json!({"x": 0, "y": 20})).unwrap(); + assert_eq!(boards(&s)[1..], [Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]); + // artboard.setProps keeps such sides when it changes something else, on a locked artboard too, + // and still raises a supplied side to 1 pt. + s.execute("artboard.setProps", &json!({"index": 1, "name": "Small"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 2, "name": "Small locked"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "height": 0.5})).unwrap(); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 1.0), Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]); } #[test] diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 3dacee533b..081db4cca0 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -191,6 +191,27 @@ fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() { } } +/// A Transform effect after a drop shadow, on the object or on the fill, scales and turns the +/// shadow on the canvas; Object › Expand of the gradient fill keeps the shadow the canvas drew. +#[test] +fn a_shadow_before_a_transform_expands_as_the_canvas_draws_it() { + for item in [json!(null), json!(0)] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(80.0, 80.0, 120.0, 120.0), white_black(), false); + for (effect, params) in [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})), + ("distort.transform", json!({"scaleH": 200, "scaleV": 200, "rotate": 90})), + ] { + run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item})); + } + let before = render(&s); + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + let diff = differing(&before, &after, 64); + assert_eq!(diff, 0, "item {item}: {diff} pixels differ"); + } +} + #[test] fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { // The object's own effect, and the fill's (the first appearance item). diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index d3394d1481..a92ca10416 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -308,6 +308,30 @@ fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() { assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did"); } +/// A Transform effect after a drop shadow, on the object or on the stroke (brushed or not), turns +/// and scales the shadow on the canvas; outlining the stroke keeps the shadow the canvas drew. +#[test] +fn a_shadow_before_a_transform_outlines_as_the_canvas_draws_it() { + let effects = [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})), + ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "rotate": 90})), + ]; + for (item, brush) in [(Value::Null, false), (json!(1), false), (json!(1), true)] { + // Flatten Transparency draws a brushed stroke's drop shadow differently with or without a + // Transform effect, so the brushed stroke is checked with the other two commands. + for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) { + let mut s = session(); + square_with(&mut s, json!({"color": "#ff0000"}), item.clone(), &effects); + if brush { + s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap(); + } + let before = render(&s); + outlined_by(&mut s, command, &p); + assert_eq!(differing(&before, &render(&s), 64), 0, "{command}, item {item}, brush {brush}"); + } + } +} + /// The brush art of a brushed stroke with a geometry effect follows the path the effect makes. #[test] fn a_brush_draws_along_the_path_the_stroke_effects_make() { diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 28f81587ab..ac39de0646 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -2109,7 +2109,7 @@ fn empty_artboard_checks_match_the_visual_bounds_on_varied_strokes() { st.cap = [LineCap::Butt, LineCap::Round, LineCap::Square][next(3) as usize]; st.align = [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside][next(3) as usize]; st.miter_limit = 1.0 + next(20) as f64; - let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize]); + let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize].clone()); (st.start_arrow, st.end_arrow) = (head(), head()); st.arrow_scale = (50.0 + next(250) as f64, 50.0 + next(250) as f64); if next(3) == 0 { diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index e7d3c7cd7a..1a6ee921f2 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1084,6 +1084,102 @@ fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() { assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}"); } +/// `document.inspect` and `document.json` for a confined caller: the document's file, a placed +/// document and a linked image outside the read roots are not named by path, and the document +/// keeps its links. +#[test] +fn document_replies_leave_out_paths_outside_the_read_roots() { + let away = Folder::new("doc-lrr-away"); + let home = Folder::new("doc-lrr-home"); + let src = away.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let mut s = session(); + let id = place(&mut s, &src); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap(); + s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap(); + let image = r["ids"][0].as_u64().unwrap(); + let poster = away.file("poster.vectorcraft"); + save(&mut s, &poster); + // The folder Export for Screens wrote to, and a pattern of the linked image being edited: its + // definition before editing is kept beside the tile art. + let out = away.file("out"); + s.execute("document.exportForScreens", &json!({"folder": out, "formats": [{"format": "svg"}]})).unwrap(); + let pattern = s.execute("object.pattern.make", &json!({"ids": [image], "edit": false})).unwrap()["name"].clone(); + s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap(); + assert!(s.doc().unwrap().doc.pattern_edit.as_ref().is_some_and(|e| e.original.is_some())); + let ask = |s: &mut Session, root: &std::path::Path, cmd: &str| { + let roots = crate::file_access::AutomationRoots::new(&[root], &[]).unwrap().unwrap(); + crate::file_access::confine(Some(&roots), || s.execute(cmd, &json!({}))).unwrap() + }; + let inspect = ask(&mut s, &home.0, "document.inspect"); + assert!(!inspect.to_string().contains("doc-lrr-away"), "{inspect}"); + assert!(inspect["path"].is_null() && inspect["uid"].is_u64(), "{inspect}"); + let model = ask(&mut s, &home.0, "document.json").to_string(); + assert!(!model.contains("doc-lrr-away"), "{model}"); + for name in ["logo.vectorcraft", "photo.png"] { + assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}"); + } + assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}"); + // The document keeps its links, and a caller whose read roots hold the files sees every path. + assert_eq!(placed(&s, id).link.path, src); + for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] { + let model = model.to_string(); + for path in [&src, &pic, &out] { + assert!(model.contains(&json!(path).to_string()), "{path}: {model}"); + } + } + assert_eq!(ask(&mut s, &away.0, "document.inspect")["path"], json!(poster)); + assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster)); +} + +/// `document.json` for a confined caller: a linked image in a brush's art outside the read roots +/// is named by its file name, a stored brush entry that does not read as a brush is left out, and a +/// link inside them whose stored relative path leads outside them loses that relative path. +#[test] +fn document_json_checks_brush_art_and_relative_paths_against_the_read_roots() { + let away = Folder::new("doc-brr-away"); + let home = Folder::new("doc-brr-home"); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let near_pic = home.file("near.png"); + write(&near_pic, &crate::tests_links::png(20, 10, RED)); + let mut s = session(); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + let image = r["ids"][0].as_u64().unwrap(); + let r = s.execute("file.place", &json!({"path": near_pic, "at": [150, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("brush.new", &json!({"type": "art", "name": "Photo Band", "ids": [image]})).unwrap(); + save(&mut s, &home.file("poster.vectorcraft")); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let lib = d.unknown.get_mut(vectorcraft_brush::DOC_KEY).and_then(Value::as_array_mut).unwrap(); + lib.push(json!({"name": "Broken Band", "type": "unknown kind", "note": pic})); + d.update_links(|l| { + if l.name() == "near.png" { + l.relative = Some("../doc-brr-away/near.png".into()); + } + }); + } + let unconfined = s.execute("document.json", &json!({})).unwrap().to_string(); + for part in [json!(pic).to_string(), "Broken Band".into(), "../doc-brr-away/near.png".into()] { + assert!(unconfined.contains(&part), "{part}: {unconfined}"); + } + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let model = crate::file_access::confine(Some(&roots), || s.execute("document.json", &json!({}))).unwrap(); + let text = model.to_string(); + assert!(!text.contains("doc-brr-away") && !text.contains("Broken Band"), "{text}"); + assert!(text.contains("\"path\":\"photo.png\"") && text.contains("Photo Band"), "{text}"); + assert!(text.contains(&json!(near_pic).to_string()), "{text}"); + // The document keeps its brushes and links. + let stored = s.doc().unwrap().doc.unknown.get(vectorcraft_brush::DOC_KEY).unwrap().to_string(); + assert!(stored.contains("Broken Band") && stored.contains(&json!(pic).to_string()), "{stored}"); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 8d1570e996..6357e81eaf 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -452,6 +452,60 @@ fn a_painted_effect_on_a_fill_and_refused_results() { } } +/// A fill's Drop Shadow before a painted effect that returns two fills, and a 200% Transform effect +/// after it: the shadow of each returned fill follows the Transform effect. The square the effect +/// returns spans 30 to 70 pt once scaled, so both half-opaque shadows, 20 pt to the right, cover +/// 85 pt as they cover 75 pt. +#[test] +fn a_shadow_on_each_fill_a_painted_effect_returns_follows_the_transform_after_it() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.shadowedfills", true), &out)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for (effect, params) in [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 50})), + ("plugin.test.engine.shadowedfills", json!({})), + ("distort.transform", json!({"scaleH": 200, "scaleV": 200})), + ] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "params": params, "item": fill})); + } + let img = drawn(&s); + let (near, far) = (img.pixel(75, 50), img.pixel(85, 50)); + assert_ne!(near, WHITE, "the shadows reach past the square"); + assert_eq!(far, near, "both shadows at 20 pt"); +} + +/// A Transform effect with Scale Strokes & Effects on after a painted effect on a fill leaves the +/// Drop Shadow after it as it is on every paint the piece is drawn with. +#[test] +fn a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.twopaints", true), &two)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twopaints", "item": fill})); + let mut n = node(&s, id); + let later = [ + vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"scaleH": 200, "scaleV": 200, "strokes": true}), visible: true }, + vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: json!({"x": 10.0, "y": 0.0, "blur": 0.0}), visible: true }, + ]; + n.appearance.items[fill].effects_mut().extend(later); + let art = vectorcraft_render::effects::reshape(Some(&s.doc().unwrap().doc), &n, None).unwrap(); + let mut offsets = vec![]; + art.walk(&mut |m| { + for item in &m.appearance.items { + for r in vectorcraft_render::effects::raster_effects(item.effects()) { + if let vectorcraft_render::effects::RasterFx::DropShadow { dx, dy, .. } = r { + offsets.push((dx, dy)); + } + } + } + }); + assert_eq!(offsets, [(10.0, 0.0), (10.0, 0.0)], "one shadow on each of the piece's two fills"); +} + /// A result refused for a swatch the document doesn't have stays cached: once the document has the /// swatch, the next check of that result clears the error. #[test] diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 1f3867a622..976d217833 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1179,6 +1179,31 @@ fn a_keeps_entry_in_a_running_apps_area_keeps_the_copies() { assert_eq!(offered(&mut b).len(), 2); } +/// An entry `/keeps-` in a crashed app's area keeps nothing while restore or +/// discard holds that area: both crashed apps' copies are restored, and discarded. +#[test] +fn a_keeps_entry_in_a_crashed_apps_area_held_by_the_same_command_keeps_nothing() { + let crashed = |store: &Arc| { + let mut s = session_in(store); + s.execute("file.recovery.save", &json!({})).unwrap(); + s.recovery.own_area().unwrap().to_string() + }; + for cmd in ["file.recovery.restore", "file.recovery.discard"] { + let store = Arc::new(MemoryStore::default()); + let a = crashed(&store); + let c = crashed(&store); + assert_ne!(a, c); + // The area that sorts first, which the command holds first, keeps the other one's copies. + let (first, second) = if a < c { (a, c) } else { (c, a) }; + store.write(&format!("{first}/keeps-{second}"), b"").unwrap(); + let mut b = relaunch(&store); + assert_eq!(offered(&mut b).len(), 2); + let r = b.execute(cmd, &json!({})).unwrap(); + let done = if cmd == "file.recovery.restore" { restored(r) } else { r["discarded"].as_array().unwrap().len() }; + assert_eq!(done, 2, "{cmd}"); + } +} + /// An entry `/keeps-` in a folder whose lock file can't be opened keeps nothing. #[cfg(unix)] #[test] diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 9932055652..5edd0d7fbd 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -84,6 +84,55 @@ fn a_confined_save_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(d); } +/// A save without a path, and Revert, refused because the document's own file is outside a confined +/// client's roots: the error keeps the root and the reason and names the file by its file name, also +/// for the files a save writes beside it. A path the client gives stays whole in the error. +#[test] +fn pathless_save_rejection_hides_document_folder() { + use crate::file_access::{AutomationRoots, confine}; + + let d = dir("roots-error"); + let (inside, outside) = (d.join("in"), d.join("kept-out")); + for f in [&inside, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let own = path(&outside, "doc.vectorcraft"); + s.execute("document.save", &json!({"path": own})).unwrap(); + rect(&mut s); + let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string(); + let hidden = outside.to_string_lossy().into_owned(); + let mut refused = |id: &str, p: Value| confine(roots.as_ref(), || s.execute(id, &p)).unwrap_err().to_string(); + + let e = refused("document.save", json!({})); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft")); + let e = refused("document.save", json!({"separateArtboards": true})); + assert!(e.starts_with(&format!("automation path rejected: outside the write root {root}: ")) && !e.contains(&hidden), "{e}"); + let e = refused("file.revert", json!({})); + assert_eq!(e, format!("automation path rejected: outside the read root {root}: doc.vectorcraft")); + // Given by the client: whole. + let e = refused("document.save", json!({"path": own})); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: {own}")); + // Without read roots, a write refusal still names the file by its file name. + let write_only = AutomationRoots::new(&[], &[inside.as_path()]).unwrap(); + let e = confine(write_only.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string(); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft")); + assert!(s.doc().unwrap().is_dirty(), "nothing was saved"); + // Read with losses from its own file: the refusal to write over it names the file by its file name. + let st = s.doc_mut().unwrap(); + st.imported_from = Some(own.clone()); + st.import_losses = vec!["hidden text".into()]; + let e = confine(roots.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string(); + assert!(e.starts_with("invalid parameters for `document.save`: doc.vectorcraft is the file this document was read from"), "{e}"); + assert!(!e.contains(&hidden), "{e}"); + let e = confine(roots.as_ref(), || s.execute("document.save", &json!({"path": own}))).unwrap_err().to_string(); + assert!(e.contains(&format!("{own} is the file this document was read from")), "{e}"); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn an_svg_path_writes_svg_and_save_writes_it_back() { let d = dir("svg"); @@ -569,6 +618,30 @@ fn eps_save_with_use_artboards_and_a_named_artboard_reports_it_as_the_range() { std::fs::remove_dir_all(d).unwrap(); } +#[test] +fn eps_save_with_use_artboards_refuses_to_replace_the_file_read_from_before_writing_any_file() { + let d = dir("eps-artboards-lossy-sibling"); + let eps_path = path(&d, "Art.eps"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap(); + let sibling = r["files"][1].as_str().unwrap().to_string(); + let original = std::fs::read(&sibling).unwrap(); + std::fs::remove_file(&eps_path).unwrap(); + let st = s.doc_mut().unwrap(); + st.imported_from = Some(sibling.clone()); + st.import_losses.push("test import omitted artwork".into()); + let err = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap_err().to_string(); + assert!(err.contains("acknowledgeLoss") && err.contains(&sibling), "{err}"); + assert_eq!(std::fs::read(&sibling).unwrap(), original); + assert!(!std::path::Path::new(&eps_path).exists(), "no file of the set is written when one is refused"); + s.execute("document.save", &json!({"path": eps_path, "options": options, "acknowledgeLoss": true})).unwrap(); + assert!(std::path::Path::new(&eps_path).exists()); + std::fs::remove_dir_all(d).unwrap(); +} + #[test] fn eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard() { let d = dir("eps-artboards"); diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs index 3171657b32..7e768c5875 100644 --- a/crates/engine/src/tests/script_engines.rs +++ b/crates/engine/src/tests/script_engines.rs @@ -230,6 +230,42 @@ fn an_engine_keeps_the_newest_places_within_its_budget() { assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept"); } +/// A run of a named engine whose script does not compile defines no code, so it keeps no place +/// and pushes out none of the places kept before. An included file without a preprocessor line is +/// first parsed with the script it joins, so its syntax error ends the run as it starts. +#[test] +fn a_run_that_does_not_compile_keeps_no_place() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-uncompiled-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + // A kept place counts the names of the files the run read: four places of the failed run's size + // would not fit in the budget. + let mut text = String::from("#targetengine e\n"); + for k in 0..10 { + std::fs::write(base.join(format!("ok{k}.jsxinc")), "var ok = 1;").unwrap(); + text.push_str(&format!("#include \"ok{k}.jsxinc\"\n")); + } + std::fs::write(base.join("bad.jsxinc"), "var = ;").unwrap(); + text.push_str("#include \"bad.jsxinc\""); + let bad = base.join("bad.jsx"); + std::fs::write(&bad, text).unwrap(); + let mut s = Session::new(); + run(&mut s, "#targetengine e\nvar x = 1;").unwrap(); + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(3000))); + run(&mut s, "#targetengine e\n\n\nfunction c() { throw new Error('c'); }").unwrap(); + let mut kinds = vec![]; + for _ in 0..5 { + assert!(s.execute("script.run", &json!({"path": bad.to_string_lossy()})).is_err()); + kinds.push(s.script_report().unwrap()["kind"].clone()); + } + assert!(run(&mut s, "#targetengine e\nc();").is_err()); + let c_place = place(&s); + TEST_KEPT_MAP_BYTES.with(|b| b.set(None)); + let _ = std::fs::remove_dir_all(base); + assert!(kinds.iter().all(|k| k == "syntax"), "{kinds:?}"); + assert_eq!((c_place.0.as_deref(), c_place.1), (Some("t.js"), 4)); +} + /// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to /// that run's script, and as `$.evalFile` does in a run given as text. #[test] diff --git a/crates/engine/src/tests/script_folder.rs b/crates/engine/src/tests/script_folder.rs index b176b4f143..312db3e0fc 100644 --- a/crates/engine/src/tests/script_folder.rs +++ b/crates/engine/src/tests/script_folder.rs @@ -311,7 +311,9 @@ fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() { s.attach_scripts_dir(root.clone()); let roots = crate::file_access::AutomationRoots::new(&[&work], &[]).unwrap().unwrap(); let e = crate::file_access::confine(Some(&roots), || s.execute("script.listInstalled", &json!({}))).unwrap_err(); - assert!(e.to_string().contains("outside the read root"), "{e}"); + // The Scripts folder is the app's: the error keeps the root and names the folder by its name. + let shown_root = crate::cmd::findfiles::plain(std::fs::canonicalize(&work).unwrap()).display().to_string(); + assert_eq!(e.to_string(), format!("automation path rejected: outside the read root {shown_root}: Scripts")); let e = crate::file_access::confine(Some(&roots), || s.installed_script(&script.to_string_lossy())).unwrap_err(); assert!(e.to_string().contains("outside the read root"), "{e}"); // A read root that holds the folder lets both through. diff --git a/crates/engine/src/tests/shaper.rs b/crates/engine/src/tests/shaper.rs index 891d03e7a7..38e61241f0 100644 --- a/crates/engine/src/tests/shaper.rs +++ b/crates/engine/src/tests/shaper.rs @@ -259,6 +259,93 @@ fn shaper_double_click_selects_an_original_for_construction_editing() { assert_eq!(s.doc().unwrap().selection.objects, vec![g]); } +/// With a Shaper Group or one of its faces selected, a Shaper click on an original shape's outline +/// enters Construction mode with that shape selected, even where the outline lies inside a merged +/// face. A click away from every outline still selects a face, and a click on an outline of a group +/// that is not selected selects the group. +#[test] +fn shaper_click_on_an_original_outline_enters_construction_mode() { + let mut s = session(); + let a = rectangle(&mut s, 0.0, "#ff0000"); + let b = rectangle(&mut s, 50.0, "#0000ff"); + let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + let sources = node(&s, g).children().unwrap()[0].id; + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("shaper", v).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); + } + }; + s.execute("select.none", &json!({})).unwrap(); + click(&mut s, 101.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + // The right side of the back shape runs through the merged face. + click(&mut s, 101.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + s.execute("object.exitIsolation", &json!({})).unwrap(); + // A click inside the face, away from the outlines, selects the face. + click(&mut s, 75.0, 50.0); + let face = s.doc().unwrap().selection.objects.clone(); + assert_eq!(face.len(), 1); + assert_ne!(face, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + // With the face selected, a click on the front shape's outline picks that shape. + click(&mut s, 149.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![b]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + s.execute("object.exitIsolation", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for tolerance in [json!("3"), json!(-1), json!(1e9)] { + assert!(s.execute("shaper.select", &json!({"point": [101, 50], "tolerance": tolerance})).is_err(), "{tolerance}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + s.execute("shaper.select", &json!({"point": [104, 50], "tolerance": 5})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + s.execute("object.exitIsolation", &json!({})).unwrap(); + // A wide tolerance (a zoomed-out view) reaches an outline from well outside the group's bounds. + s.execute("shaper.select", &json!({"point": [165, 50], "tolerance": 20})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![b]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + // A locked or hidden original shape is not picked by its outline. + for hide in ["object.lock", "object.hide"] { + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute(hide, &json!({})).unwrap(); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("shaper.select", &json!({"point": [149, 50]})).unwrap(); + assert_ne!(s.doc().unwrap().selection.objects, vec![b], "{hide}"); + assert_eq!(s.doc().unwrap().isolation, None, "{hide}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + } +} + +/// A click on a selected Shaper Group's original outline where another Shaper Group lies above it +/// selects the group above, as a click there did before. +#[test] +fn shaper_outline_click_under_another_group_selects_the_group_above() { + let mut s = session(); + rectangle(&mut s, 0.0, "#ff0000"); + rectangle(&mut s, 50.0, "#0000ff"); + let lower = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + let c = rectangle(&mut s, 20.0, "#00ff00"); + let d = rectangle(&mut s, 70.0, "#ffff00"); + let upper = NodeId( + s.execute("shaper.scribble", &json!({"points": [[45, 20], [95, 35], [50, 50], [145, 65]], "ids": [c.0, d.0]})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("select.set", &json!({"ids": [lower.0]})).unwrap(); + s.execute("shaper.select", &json!({"point": [100, 50]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![upper]); + assert_eq!(s.doc().unwrap().isolation, None); +} + /// The arrow widget beside a selected Shaper Group switches it to Construction mode and back, with /// the Shaper tool and with the Selection tool. In Construction mode a Shaper tap selects a source /// shape, and the mode lasts through undo and redo of an edit made in it. diff --git a/crates/engine/src/tests/strokegeom.rs b/crates/engine/src/tests/strokegeom.rs index f7500af358..524dabef3e 100644 --- a/crates/engine/src/tests/strokegeom.rs +++ b/crates/engine/src/tests/strokegeom.rs @@ -51,7 +51,7 @@ fn profiles_come_from_the_preset_catalogue() { assert!(spec.params.contains("arrowAlign")); // …and every arrowhead name `startArrow`/`endArrow` accept. for a in vectorcraft_doc::Arrowhead::ALL { - let name = serde_json::to_value(a).unwrap(); + let name = serde_json::to_value(&a).unwrap(); assert!(spec.params.contains(name.as_str().unwrap()), "{name}"); s.execute("stroke.set", &json!({"endArrow": name})).unwrap(); assert_eq!(stroke(&s, id).end_arrow, Some(a)); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 46d41adaea..adcc35d352 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -87,6 +87,50 @@ fn set_range_style_splits_runs() { assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "size": -1})).is_err()); } +/// `text.create`, `text.createInPath`, `text.setStyle` and `text.setRangeStyle` refuse a size +/// outside 0.1 to 1296 pt with an error naming the range, and change nothing; both ends are taken. +#[test] +fn a_type_size_outside_its_range_is_refused() { + let mut s = session(); + let message = "`size` must be from 0.1 to 1296"; + let state = |s: &Session| (s.doc().unwrap().doc.node_count(), s.doc().unwrap().history.undo.len()); + let before = state(&s); + for size in [2835.0, 1296.5, 0.05, 0.0, -1.0] { + let e = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": size})).unwrap_err().to_string(); + assert!(e.contains(message), "{size}: {e}"); + } + assert_eq!(state(&s), before, "no type is made"); + let big = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": 1296})).unwrap(); + let big = NodeId(big["id"].as_u64().unwrap()); + assert_eq!(obj(&s, big).runs[0].style.size, 1296.0); + let small = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "small", "size": 0.1})).unwrap(); + assert_eq!(obj(&s, NodeId(small["id"].as_u64().unwrap())).runs[0].style.size, 0.1); + let path = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone(); + let before = state(&s); + let e = s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 2835})).unwrap_err().to_string(); + assert!(e.contains(message), "{e}"); + assert_eq!(state(&s), before); + s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 1296})).unwrap(); + let id = text(&mut s, "Hello"); + let before = state(&s); + for (cmd, params) in [ + ("text.setStyle", json!({"ids": [id.0], "size": 2835})), + ("text.setStyle", json!({"ids": [id.0], "size": "1300 pt"})), + ("text.setStyle", json!({"ids": [id.0], "size": 0.05})), + ("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 2835})), + ("text.setRangeStyle", json!({"id": id.0, "size": 0})), + ] { + let e = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(e.contains(message), "{cmd} {params}: {e}"); + } + assert_eq!(state(&s), before, "nothing changes"); + assert_eq!(obj(&s, id).runs[0].style.size, 20.0); + s.execute("text.setStyle", &json!({"ids": [id.0], "size": 1296})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.size, 1296.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "size": 0.1})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.size, 0.1); +} + /// `featureChanges` turns features on and off on top of each run's own and keeps the rest, in /// `text.setStyle` and `text.setRangeStyle`, in one undo step; an unknown tag is an error. #[test] @@ -524,6 +568,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { ("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"), ("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"), ("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"), + ("text.setFormat", json!({"ids": [id.0], "bunriKinshi": "on"}), "bunriKinshi"), ("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"), ("type.opticalMarginAlignment", json!({"on": "true"}), "on"), ("type.systemLayout", json!({"on": "true"}), "on"), diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index 4b773c889c..08ba426ef1 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2916,3 +2916,191 @@ fn applying_a_graph_row_again_records_no_undo_step() { s.execute("dataset.select", &json!({"name": "B"})).unwrap(); assert_eq!(super::history(&s), (before.0 + 1, 0, true)); } + +/// The document's SVG with the options `svg` → (its text, the export's warnings). +fn svg_export(s: &mut Session, svg: Value) -> (String, Vec) { + let r = s.execute("document.export", &json!({"format": "svg", "svg": svg})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + (String::from_utf8(bytes).unwrap(), r["warnings"].as_array().cloned().unwrap_or_default()) +} + +/// A new session with `svg` opened as `name` → (the session, the open's warnings). +fn svg_opened(svg: &str, name: &str) -> (Session, Vec) { + let mut o = Session::new(); + let r = o.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); + (o, warnings) +} + +/// Include Variable Data writes the text variables, the data sets' text values and each text +/// variable's bound type object into the SVG, and opening the SVG reads them back: the type +/// objects are bound again, so a data set changes them. Variables of other kinds are left out with +/// a warning. Unnamed objects get ids with Minimal ids, minified files, unique ids and style +/// entities alike, and Load Variable Library reads the same file. +#[test] +fn an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let u = text(&mut s, "9.99"); + // A fill of its appearance writes the type in a group with the fill's path. + s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap(); + let r = rect(&mut s); + for (name, kind) in [("Name", "text"), ("Shown", "visibility"), ("Price", "text")] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + } + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Shown", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A & ", "values": {"Name": "Bob", "Price": "1.00\nper pound & tax\r", "Shown": false}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Second", "values": {"Name": ""}})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + let text_only: Vec = before + .datasets + .iter() + .map(|d| { + let mut d = d.clone(); + d.values.retain(|k, _| k != "Shown"); + d + }) + .collect(); + + for opts in [ + json!({"variableData": true}), + json!({"variableData": true, "objectIds": "minimal", "minify": true}), + json!({"variableData": true, "objectIds": "unique"}), + json!({"variableData": true, "styling": "entities", "encoding": "latin1"}), + ] { + let (svg, warnings) = svg_export(&mut s, opts.clone()); + assert!(warnings.contains(&json!("Variables that aren't text variables were left out of the SVG's variable data")), "{opts}: {warnings:?}"); + assert_eq!(svg.matches("docRef=\"id('").count(), 2, "{opts}: {svg}"); + assert_eq!(s.doc().unwrap().doc.variables, before, "{opts}: exporting changes nothing"); + + let (mut o, warnings) = svg_opened(&svg, "vars.svg"); + assert!(warnings.is_empty(), "{opts}: {warnings:?}"); + let vars = o.doc().unwrap().doc.variables.clone(); + assert_eq!(vars.variables.iter().map(|v| (v.name.as_str(), v.kind.label())).collect::>(), [("Name", "text"), ("Price", "text")]); + assert_eq!(vars.datasets, text_only, "{opts}"); + assert_eq!(vars.active_dataset, None); + let bound = |name: &str| { + let ids = vars.objects_of(name); + assert_eq!(ids.len(), 1, "{opts}: {name}"); + ids[0] + }; + let (name, price) = (bound("Name"), bound("Price")); + assert_eq!((plain(&o, name), plain(&o, price)), ("Alice".into(), "9.99".into()), "{opts}: the art stays as the file draws it"); + let v = o.execute("dataset.select", &json!({"name": "A & "})).unwrap(); + assert_eq!(v["applied"], json!(2), "{opts}: {v}"); + assert_eq!(plain(&o, name), "Bob"); + + let mut lib = session(); + lib.execute("variable.loadLibrary", &json!({"xml": svg})).unwrap(); + assert_eq!(lib.doc().unwrap().doc.variables.datasets, text_only, "{opts}"); + } +} + +/// Without Include Variable Data the SVG holds no variable data, and a document without variables +/// writes none with it on. Outlined type is written unbound, characters XML can't hold are left +/// out of the values, and variable data past the 8 MiB or 2,000,000-node cap is left out, each +/// with a warning; past 256 text variables or 1,000 data sets the rest are left out with a +/// warning. Look-alike text in comments and other markup is skipped when the file opens. A file +/// whose variable data can't be read opens its art with a warning, and so does one whose +/// namespace entities would grow the library past its cap. +#[test] +fn an_svg_without_readable_variable_data_opens_its_art() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let (plain_svg, _) = svg_export(&mut s, json!({})); + assert!(!plain_svg.contains("variableSets")); + let (none, _) = svg_export(&mut s, json!({"variableData": true})); + assert!(!none.contains("variableSets"), "no variables, no variable data"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + let (off, _) = svg_export(&mut s, json!({})); + assert!(!off.contains("variableSets"), "off by default"); + // Outlined type opens as art: the variable is written unbound, and the values lose the + // characters XML can't hold, each with a warning. + s.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\u{b}b\u{1}c"}})).unwrap(); + let (outlined, warnings) = svg_export(&mut s, json!({"variableData": true, "outlineText": true})); + assert!(outlined.contains(""; + let art = ""; + let untraited = format!("{head}{art}"); + let refs = "&ns_big;".repeat(40_000); + let big = "x".repeat(250); + let growing = format!( + "]>{head}{art}" + ); + // Variable data under 8 MiB with more XML nodes than Load Variable Library reads (a million + // one-character paragraphs, minified) is left out with a warning. + let mut lines = session(); + lines.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + lines.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\n".repeat(1_000_000)}})).unwrap(); + let (dense, warnings) = svg_export(&mut lines, json!({"variableData": true, "minify": true})); + assert!(!dense.contains("variableSets")); + assert!( + warnings.contains(&json!( + "The variable data holds more than 2,000,000 XML nodes, which Load Variable Library reads, and was left out of the SVG" + )), + "{warnings:?}" + ); + + // More text variables and data sets than Load Variable Library reads: the first 256 and the + // first 1,000 are written, each with a warning, and the file opens with them. + let mut many = session(); + { + let vars = &mut std::sync::Arc::make_mut(&mut many.doc_mut().unwrap().doc).variables; + vars.variables = (0..300).map(|i| vectorcraft_doc::Variable { name: format!("V{i}"), kind: vectorcraft_doc::VariableKind::Text }).collect(); + vars.datasets = (0..1_100) + .map(|i| vectorcraft_doc::DataSet { + name: format!("D{i}"), + values: [("V0".to_string(), vectorcraft_doc::DataValue::Text(format!("{i}")))].into_iter().collect(), + }) + .collect(); + } + let (capped, warnings) = svg_export(&mut many, json!({"variableData": true})); + for w in [ + "Text variables past the first 256, which Load Variable Library reads, were left out of the SVG's variable data", + "Data sets past the first 1,000, which Load Variable Library reads, were left out of the SVG's variable data", + ] { + assert!(warnings.contains(&json!(w)), "{warnings:?}"); + } + let (o, warnings) = svg_opened(&capped, "many.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + let vars = &o.doc().unwrap().doc.variables; + assert_eq!((vars.variables.len(), vars.datasets.len()), (256, 1_000)); + assert_eq!(vars.datasets[999].values["V0"], vectorcraft_doc::DataValue::Text("999".into())); + + // Text that only looks like variable data, in a comment, a CDATA section, a processing + // instruction or the document type declaration, is skipped for the element itself. + let library = ""; + let decoys = format!( + " -->]>{head} ]]> ?>{library}{art}" + ); + let (o, warnings) = svg_opened(&decoys, "decoys.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(o.doc().unwrap().doc.variables.variables.len(), 1); + + for (svg, why) in [(untraited, "variable `A` has no `trait`"), (growing, "entities expand to more than")] { + let (o, warnings) = svg_opened(&svg, "broken.svg"); + assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("variable data wasn't read") && w.contains(why))), "{warnings:?}"); + let d = &o.doc().unwrap().doc; + assert!(d.variables.variables.is_empty()); + assert_eq!(vectorcraft_testkit::svg_shapes(&vectorcraft_svg::export(d, &Default::default())), 1, "the art opens"); + } +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 4f8188dbe7..ad1549140f 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1090,22 +1090,51 @@ impl Session { self.tool.resizing() } - /// With Real-time Drawing off, how far a Selection-tool move drag has carried the selection - /// so far: the document moves on release, and the bounding box shows at this offset meanwhile. - pub fn deferred_move(&self) -> Option { - if self.tool.id() != "selection" || !self.tool.transforming() { + /// Does the canvas draw the bounding box around a shape being drawn: the active tool is drawing + /// a new shape whose box it shows once drawn (see `Tool::drawing_boxed`), and General › Show + /// Bounding Box While Moving and Drawing is on? + pub fn draws_new_shape_box(&self) -> bool { + self.prefs.bounding_box_while_moving_and_drawing && self.tool.drawing_boxed() + } + + /// Has the interaction no preview applied to the document: none has run yet, the last one failed, + /// or Real-time Drawing is off and the document is still the interaction's snapshot? + pub fn interaction_unapplied(&self) -> bool { + self.active().is_some_and(|d| d.interaction.as_ref().is_some_and(|it| it.preview.is_none() || std::sync::Arc::ptr_eq(&it.doc, &d.doc))) + } + + /// The bounding box of the selection a move drag is carrying (an Alt-drag copy included), where + /// the drag has carried it: the box of the selection at the drag's start, measured once per + /// drag, offset by the move of the latest preview. With General › Show Bounding Box While + /// Moving and Drawing on, for every tool that moves the selection; off, only for the Selection + /// tool with Real-time Drawing off, where the document moves on release. `None` for any other + /// drag, and before a preview has moved the selection. + pub fn move_drag_box(&self) -> Option { + if !self.tool.moving() { + return None; + } + let st = self.active()?; + let it = st.interaction.as_ref()?; + let deferred = std::sync::Arc::ptr_eq(&it.doc, &st.doc); + if !self.prefs.bounding_box_while_moving_and_drawing && !(self.tool.id() == "selection" && deferred) { return None; } - let it = self.active()?.interaction.as_ref()?; if !matches!(it.label.as_str(), "Move" | "Copy") { return None; } - let (cmd, p) = self.unapplied_interaction_preview()?; + let (cmd, p) = it.preview.as_ref()?; if cmd != "object.transform" || p.get("objects").and_then(Value::as_bool) == Some(false) { return None; } - let [a, b, c, d, e, f] = crate::cmd::matrix_param(&p, "matrix")?.as_coeffs(); - (a == 1.0 && b == 0.0 && c == 0.0 && d == 1.0).then(|| vectorcraft_geom::Vec2::new(e, f)) + let [a, b, c, d, e, f] = crate::cmd::matrix_param(p, "matrix")?.as_coeffs(); + if !(a == 1.0 && b == 0.0 && c == 0.0 && d == 1.0) { + return None; + } + let measure = self.bounds_measure(); + let start = + (*it.move_box.get_or_init(|| vectorcraft_render::effects::preview::oriented_bounds_with(&it.doc, &it.selection.objects, measure)))?; + let moved = start.to_local(Point::new(e, f)).to_vec2(); + Some(vectorcraft_doc::OrientedBox { rect: start.rect + moved, ..start }) } /// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)? @@ -1438,23 +1467,30 @@ impl Session { } /// With Real-time Drawing off, the selection's transform box ([`Session::transform_box`]) as the - /// pending preview of a handle resize leaves it, measured once per preview step; `None` when no - /// such preview is pending, or when the preview fails. - pub fn deferred_transform_box(&mut self) -> Option { - if !self.tool.resizing() { + /// pending preview of a handle resize or of a shape being drawn leaves it, measured once per + /// preview step, with the layer color of the first object it measures; `None` when no such + /// preview is pending, or when the preview fails. + pub fn deferred_transform_box(&mut self) -> Option<(vectorcraft_doc::OrientedBox, [u8; 3])> { + if !self.measures_deferred_box() { return None; } let (cmd, p) = self.unapplied_interaction_preview()?; self.deferred_feedback(&cmd, &p)?.transform_box } + /// Does the canvas draw the bounding box of what the pending preview leaves: during a handle + /// resize, or while a shape is drawn by a tool that shows its box ([`Session::draws_new_shape_box`])? + fn measures_deferred_box(&self) -> bool { + self.tool.resizing() || self.draws_new_shape_box() + } + fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec { self.deferred_feedback(cmd, p).map(|f| f.overlays.clone()).unwrap_or_default() } /// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take - /// outlines from the objects it touched (and, during a handle resize, their transform box), then - /// put the document back at the interaction snapshot. + /// outlines from the objects it touched (and, during a handle resize or while a shape is drawn, + /// their transform box), then put the document back at the interaction snapshot. fn deferred_feedback(&mut self, cmd: &str, p: &Value) -> Option<&DeferredFeedback> { if !self.deferred_overlay_cache.as_ref().is_some_and(|f| f.cmd == cmd && f.params == *p) { let feedback = self.measure_deferred_preview(cmd, p)?; @@ -1475,8 +1511,12 @@ impl Session { }; let (overlays, transform_box) = if self.preview(cmd, p).is_ok() { let overlays = self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default(); - let objects = self.active().map(|st| st.selection.objects.clone()).unwrap_or_default(); - (overlays, if self.tool.resizing() { self.transform_box(&objects) } else { None }) + let (objects, color) = self + .active() + .map(|st| (st.selection.objects.clone(), st.selection.objects.first().map(|id| st.doc.layer_color(*id)))) + .unwrap_or_default(); + let transform_box = if self.measures_deferred_box() { self.transform_box(&objects).zip(color) } else { None }; + (overlays, transform_box) } else { (vec![], None) }; @@ -1500,7 +1540,7 @@ pub(crate) struct DeferredFeedback { cmd: String, params: Value, overlays: Vec, - transform_box: Option, + transform_box: Option<(vectorcraft_doc::OrientedBox, [u8; 3])>, } /// The most points the outlines of [`outlines_for_selection`] are drawn for, the same number as the diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index 0cc0edc4d4..c3d6df771b 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -57,6 +57,24 @@ fn legacy_profile_name_resolves_in_commands_and_files() { cms::set_active(&before).unwrap(); } +/// Assigning a profile other than the working space replaces the active color settings and raises +/// the other open documents' revisions. +#[test] +fn assigning_another_profile_replaces_the_active_color_settings() { + let _g = settings(); + let mut s = session(); + exec(&mut s, "file.new", json!({"width": 100, "height": 100})); + let before = cms::active_settings(); + assert_ne!(before.cmyk, cms::DEVICE_CMYK); + let active = cms::active(); + let other = s.documents()[0].revision; + exec(&mut s, "edit.assignProfile", json!({"cmyk": cms::DEVICE_CMYK})); + assert_eq!(cms::active_settings().cmyk, cms::DEVICE_CMYK); + assert!(!std::sync::Arc::ptr_eq(&active, &cms::active()), "the active color settings were not replaced"); + assert!(s.documents()[0].revision > other, "the other document's revision did not change"); + cms::set_active(&before).unwrap(); +} + /// Separations Preview follows the Color Settings and the profiles loaded: a renderer draws a gray /// square on the Black plate through the new working CMYK profile, and the four process plates /// through a proof profile loaded again under the same name, as a new renderer draws them. @@ -428,6 +446,60 @@ fn loading_the_working_rgb_profile_again_takes_effect_at_once() { assert!(redrawn, "the canvas redraws"); } +/// Loading a new file of the CMYK profile View → Proof Setup targets, or removing that profile, +/// gives the documents new revisions, as a working space's new file does; loading or removing +/// another profile does not. +#[test] +fn loading_or_removing_the_proof_profile_redraws() { + let _g = settings(); + // A built-in CMYK profile's file renamed `name`: padded with spaces, which names drop. + let renamed = |builtin: &str, name: &str| { + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(&format!("{name: revision, "the proof profile's file"); + exec(&mut s, "color.removeProfile", json!({"name": other})); + assert_eq!(s.doc().unwrap().revision, reloaded, "another profile removed"); + exec(&mut s, "color.removeProfile", json!({"name": proofed})); + assert!(s.doc().unwrap().revision > reloaded, "the proof profile removed"); +} + +/// Sets the default proof view and unregisters the profiles when dropped, also after a failed +/// assertion. +struct ProofReset([&'static str; 2]); + +impl Drop for ProofReset { + fn drop(&mut self) { + vectorcraft_render::proof::set_view(Default::default()); + for name in self.0 { + cms::unregister_icc(name); + } + } +} + /// A folder of its own for the test `tag`, empty. fn fresh_dir(tag: &str) -> std::path::PathBuf { let dir = vectorcraft_testkit::temp_dir(&format!("color-settings-{tag}")); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4542496fc5..4688845de4 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2958,6 +2958,13 @@ fn the_hpgl_sample_opens() { assert!(paths > 15, "{paths} paths, warnings {:?}", l.warnings); } +/// A wedge drawn with the adaptive line type `LT-1` and a near-zero radius (a case the fuzz test +/// found) opens and places without a panic. +#[test] +fn an_adaptive_line_type_wedge_of_almost_no_radius_opens() { + survive_metafile("HP-GL tiny adaptive wedge", "x.plt", b"IN;SP1;LT-1;EW0.000001,0,0;").unwrap(); +} + /// A random instruction: a mnemonic the reader plays (or one it doesn't) with hostile numbers. fn arb_hpgl_instruction() -> impl Strategy { let op = prop::sample::select(vec![ diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 0ccfc23ca8..2bc1746ea1 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -294,6 +294,134 @@ fn writing_again_gives_the_same_bytes() { assert!(first.warnings.is_empty(), "{:?}", first.warnings); } +/// The CMYK equivalent written beside RGB or Lab values is the one of the values as written (to four +/// decimals, Lab clamped to the range a reader reads), so the reopened file is written with the same +/// numbers: a new document's swatches, and colors of many decimals in fills, gradient stops, global +/// process swatches (RGB and Lab), an RGB spot swatch (in a fill and in a stop), Lab spot swatches +/// and a Lab spot swatch out of range. +#[test] +fn cmyk_equivalents_are_written_again_unchanged() { + let mut doc = Document::new(300.0, 200.0); + let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + let rgb = |i: u16| Color::rgb(v(i, 0.137_911_3), v(i, 0.271_828_2), v(i, 0.314_159_3)); + let lab = |i: u16| Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0); + let glaze = rgb(102); + let swatch = |name: String, c: Color, spot: bool| Swatch { name, paint: solid(c), global: true, spot }; + let mut named: Vec<(String, Color, bool)> = + vec![("Glaze".into(), glaze, true), ("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65), true)]; + for i in 0..8u16 { + named.push((format!("Leaf {i}"), rgb(i + 110), false)); + named.push((format!("Fern {i}"), lab(i + 120), false)); + named.push((format!("Moss {i}"), lab(i + 130), true)); + } + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.extend(named.iter().map(|(name, c, spot)| swatch(name.clone(), *c, *spot))); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + for i in 0..24u16 { + let x = f64::from(i % 8) * 30.0 + 10.0; + let y = f64::from(i / 8) * 30.0 + 10.0; + let n = square(&mut doc, Rect::new(x, y, x + 20.0, y + 20.0), rgb(i)); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let mut stops: Vec = (0..5u16).map(|i| GradientStop::new(f32::from(i) / 5.0, rgb(i + 50))).collect(); + let out = named[1].1; + stops.push(GradientStop { swatch: Some("Out".into()), tint: 0.5, ..GradientStop::new(0.9, out.tinted(0.5)) }); + stops.push(GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) }); + let mut fills = vec![gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None)]; + fills.extend(named.iter().map(|(name, c, spot)| linked(name, *c, if *spot { 1.0 } else { 0.6 }))); + for (i, fill) in fills.into_iter().enumerate() { + let x = (i % 9) as f64 * 30.0 + 10.0; + let y = (i / 9) as f64 * 30.0 + 110.0; + let n = painted(&mut doc, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let first = program(&doc); + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n"); + same_lines(&again.lines().map(String::from).collect::>(), &first.lines().map(String::from).collect::>()); +} + +/// With a working RGB space other than sRGB, an RGB fill, an RGB gradient stop and an RGB spot +/// color in a stop are written with their working RGB values, the values the reader reads back, so +/// the reopened file is written with the same RGB values and CMYK equivalents. +#[test] +fn rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space() { + const CHILD: &str = "VECTORCRAFT_TEST_AI_EDITING_WIDE_GAMUT"; + if std::env::var_os(CHILD).is_none() { + // Color settings are process-wide: exercise this profile in a separate test process. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "ai_editing::tests::rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "isolated color profile test failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + return; + } + use vectorcraft_color::cms; + cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap(); + assert!(!cms::active().rgb_is_srgb()); + let mut doc = Document::new(300.0, 200.0); + let glaze = Color::rgb(0.812_345_6, 0.234_567_8, 0.456_789_1); + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { name: "Glaze".into(), paint: solid(glaze), global: true, spot: true }); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + let n = square(&mut doc, Rect::new(10.0, 10.0, 30.0, 30.0), Color::rgb(0.8, 0.4, 0.2)); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let stops = vec![ + GradientStop::new(0.0, Color::rgb(0.1, 0.7, 0.3)), + GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) }, + ]; + let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None); + let n = painted(&mut doc, shapes::rectangle(Rect::new(40.0, 10.0, 60.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let first = program(&doc); + assert!(first.contains("0.8 0.4 0.2 Xa"), "the fill's working RGB values are written"); + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n"); + same_lines(&again.lines().map(String::from).collect::>(), &first.lines().map(String::from).collect::>()); +} + +/// A gradient stop of a Lab spot color writes the RGB values of the Lab values as written (to four +/// decimals, clamped to the range a reader reads), the same Lab values its CMYK equivalent and the +/// palette's definition come from: for Lab values of many decimals and for Lab values out of range. +#[test] +fn a_lab_spot_stop_writes_rgb_values_of_its_lab_values_as_written() { + let mut doc = Document::new(300.0, 200.0); + let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + let mut spots: Vec<(String, Color)> = vec![("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65))]; + spots.push(("Rim".into(), Color::lab(55.555_55, 131.234_56, -12.345_67))); + for i in 0..8u16 { + spots + .push((format!("Moss {i}"), Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0))); + } + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.extend(spots.iter().map(|(name, c)| Swatch { name: name.clone(), paint: solid(*c), global: true, spot: true })); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + for (i, (name, c)) in spots.iter().enumerate() { + let stops = vec![ + GradientStop::new(0.0, Color::rgb(0.1, 0.2, 0.3)), + GradientStop { swatch: Some(name.clone()), tint: 0.5, ..GradientStop::new(1.0, c.tinted(0.5)) }, + ]; + let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None); + let x = i as f64 * 25.0 + 10.0; + let n = painted(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let program = program(&doc); + for (name, c) in &spots { + let mut want = String::new(); + crate::ps::push_nums(&mut want, &crate::ai_ops::as_written(*c).to_rgb().map(|v| f64::from(crate::ai_ops::unit(v)))); + want.push_str(&format!("({name}) 0.5 1 4 ")); + assert!(program.lines().any(|l| l.contains(&want)), "no stop of {name} writes {want:?}"); + } +} + #[test] fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { let doc = sample(); @@ -3815,6 +3943,42 @@ fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() { } } +/// The text document has no Bunri-Kinshi: a paragraph with it on and an inseparable pair in it is +/// reported as left out, and comes back off; with it off, or with no pair, nothing is reported. +#[test] +fn bunri_kinshi_is_reported_only_for_paragraphs_with_a_pair() { + use vectorcraft_doc::TextObject; + for (text, on, reported) in [ + ("Wait——now", true, true), + ("Wait——now", false, false), + ("Wait—now", true, false), + ("Wait……now", true, true), + ("Wait‥‥now", true, true), + ("あ〳〵い", true, true), + ("あ〴〵い", true, true), + ("あ〵〳い", true, false), + ] { + let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK)); + t.para.bunri_kinshi = on; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings); + let mut all = vec![]; + paths(&read_back(&doc).layers, &mut all); + let paras: Vec = all + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.para_at(0).bunri_kinshi), + _ => None, + }) + .collect(); + assert_eq!(paras, [false], "{text} {on}"); + } +} + #[test] fn type_is_written_again_as_the_same_bytes() { let doc = typed(); @@ -5872,25 +6036,6 @@ fn outlines(doc: &Document) -> Vec { out } -/// Fails with the lines where `got` and `want` differ in more than their numbers' last written -/// decimal (a color read back from four decimals gives its other model's values again from them). -fn near_lines(got: &str, want: &str) { - let near = |a: &str, b: &str| { - let (a, b): (Vec<&str>, Vec<&str>) = (a.split(' ').collect(), b.split(' ').collect()); - a.len() == b.len() - && a.iter().zip(&b).all(|(x, y)| match (x.trim_start_matches("%_").parse::(), y.trim_start_matches("%_").parse::()) { - (Ok(x), Ok(y)) => (x - y).abs() <= 2e-3, - _ => x == y, - }) - }; - let (g, w): (Vec<&str>, Vec<&str>) = (got.lines().collect(), want.lines().collect()); - let differ: Vec = (0..g.len().max(w.len())) - .filter(|&i| !matches!((g.get(i), w.get(i)), (Some(a), Some(b)) if near(a, b))) - .map(|i| format!("line {i}:\n got {:?}\n want {:?}", g.get(i), w.get(i))) - .collect(); - assert!(differ.is_empty(), "{}", differ.join("\n")); -} - #[test] fn objects_with_live_effects_and_graphic_styles_are_read_back() { let doc = styles_document(); @@ -6212,25 +6357,7 @@ fn art_styles_write_the_same_bytes_again() { assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); // What is read back is written as it was. let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; - near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program))); -} - -/// `program` with the CMYK equivalents of plain RGB colors blanked out: the first four numbers of -/// `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. Named and -/// spot RGB colors (`... (name) tint Xk`, `Xx` and spot stops) are not blanked; the document this -/// is used on has none. The Generic CMYK separation of a color at the edge of the press gamut can -/// land on one of two results that differ by up to 0.01 when the RGB changes in its last float -/// bits, as it does between the first save and the save of the reopened file, and the platform's -/// float math decides which. The RGB values stay compared. -fn rgb_without_cmyk(program: &str) -> String { - let blank = |line: &str| { - let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r)); - let t: Vec<&str> = rest.split(' ').collect(); - let color = t.len() == 8 && matches!(t[7], "Xa" | "XA"); - let stop = t.len() > 8 && t[7] == "2" && t.last() == Some(&"Bs"); - if color || stop { format!("{prefix}_ _ _ _ {}", t[4..].join(" ")) } else { line.to_string() } - }; - program.lines().map(blank).collect::>().join("\n") + same_lines(&shown(&again).lines().map(String::from).collect::>(), &shown(&first.program).lines().map(String::from).collect::>()); } /// `n` on the first layer of `doc`. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 873c607b90..1e04f6e100 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1357,8 +1357,9 @@ impl<'a> Writer<'a> { self.plain_color(*color, stroke); } - /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and - /// Lab as RGB), with its CMYK equivalent. + /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB in the + /// working RGB space, and Lab as display RGB), with its CMYK equivalent (an RGB color's from its + /// values as written, a Lab color's from its Lab values). fn plain_color(&mut self, color: Color, stroke: bool) { let s = &mut self.body; let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill }; @@ -1373,19 +1374,22 @@ impl<'a> Writer<'a> { let _ = writeln!(s, "{}", op("k", "K")); } Color::Rgb { .. } | Color::Lab { .. } => { - let cmyk = self.cms.to_cmyk(&color, self.cms.settings().intent).map(unit); + let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; + let cmyk = self.cms.to_cmyk(&source, self.cms.settings().intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); - ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from)); let _ = writeln!(s, "{}", op("Xa", "XA")); } } } /// A spot (`spot`) or global process color `name` of full color `full` at `tint`, written to - /// `s`: its CMYK equivalent, its RGB or Lab values, the name, how much lighter than the full - /// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before - /// `Xx` (spot) or `Xk` (global process). + /// `s`: its CMYK equivalent (from its RGB or Lab values as written), its RGB or Lab values + /// (clamped as [`ai_ops::as_written`] clamps them), the name, how much lighter than the full + /// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before `Xx` + /// (spot) or `Xk` (global process). fn named(&self, s: &mut String, name: &str, full: Color, tint: f32, spot: bool, stroke: bool) { + let full = ai_ops::as_written(full); let cmyk = self.cms.to_cmyk(&self.doc.linked_color(full, spot), self.cms.settings().intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); let model = match full { @@ -1481,7 +1485,8 @@ impl<'a> Writer<'a> { true } - /// The spot color swatches, with their full colors' CMYK equivalents, for the header. + /// The spot color swatches, with their full colors' CMYK equivalents (as the palette writes + /// them), for the header. pub(super) fn spot_swatches(&self) -> Vec<(String, [f32; 4])> { let intent = self.cms.settings().intent; self.doc @@ -1489,7 +1494,7 @@ impl<'a> Writer<'a> { .filter(|sw| sw.spot) .filter_map(|sw| match sw.paint { Paint::Solid { color, swatch: None, .. } => { - Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(color, true), intent).map(unit))) + Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(ai_ops::as_written(color), true), intent).map(unit))) } _ => None, }) diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index f370167bcf..0a7220faa5 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -11,7 +11,7 @@ use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; use super::{MAX_NEST, Writer, Xy}; -use crate::ai_ops::{average, unit}; +use crate::ai_ops::{self, average, unit}; use crate::ps; pub(in crate::ai_editing) const GRADIENTS: &str = @@ -371,7 +371,11 @@ impl Writer<'_> { /// A stop's color and its style: gray `lightness 0`, CMYK `c m y k 1`, RGB (and Lab, as RGB) /// `c m y k r g b 2` with its CMYK equivalent; a spot color its full color's CMYK equivalent, /// then its RGB values when it isn't CMYK, its name and how much lighter than the full color, - /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). + /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). RGB values are an RGB color's own (in the + /// working RGB space) and a Lab color's display RGB ([`ai_ops::rgb_values`]). An RGB color's CMYK + /// equivalent is converted from its values as written ([`ai_ops::as_written`]), a Lab color's + /// from its Lab values, and a spot color's CMYK equivalent and RGB values both come from its full + /// color as the palette writes it. fn stop_color(&mut self, s: &mut String, stop: &GradientStop) { let doc = self.doc; let intent = self.cms.settings().intent; @@ -380,11 +384,12 @@ impl Writer<'_> { && let Paint::Solid { color: full, swatch: None, .. } = sw.paint { if sw.spot { + let full = ai_ops::as_written(full); let cmyk = self.cms.to_cmyk(&doc.linked_color(full, true), intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); let rgb = !matches!(full, Color::Cmyk { .. }); if rgb { - ps::push_nums(s, &full.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(full).map(f64::from)); } s.push_str(&ps::bytes(name.as_bytes())); s.push(' '); @@ -406,9 +411,10 @@ impl Writer<'_> { s.push_str("1 "); } color @ (Color::Rgb { .. } | Color::Lab { .. }) => { - let cmyk = self.cms.to_cmyk(&color, intent).map(unit); + let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; + let cmyk = self.cms.to_cmyk(&source, intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); - ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from)); s.push_str("2 "); } } diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 1d791afadf..1e47ca1564 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -387,6 +387,7 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { } let hangul = text.chars().any(|c| matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{FFA0}'..='\u{FFDC}')); + let pairs = text.chars().zip(text.chars().skip(1)).any(|(a, b)| vectorcraft_text::inseparable(a, b)); let held = ParaStyle { justify: p.justify, left_indent: p.left_indent, @@ -403,6 +404,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // The text document has no Korean Line Break Anywhere: it reads back off, which // changes only the lines of a paragraph with Hangul in it. korean_line_break_anywhere: p.korean_line_break_anywhere && !hangul, + // Nor Bunri-Kinshi: it reads back off, which changes only the lines of a paragraph + // with an inseparable pair in it. + bunri_kinshi: p.bunri_kinshi && !pairs, ..ParaStyle::default() }; *p != held diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index 7707f4bc45..3b2bdd88d8 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -103,6 +103,27 @@ pub(crate) fn unit(v: f32) -> f32 { if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } } +/// `color` as a reader reads it back from the values written for it ([`ps::written`]): RGB values +/// clamped to 0..1, L to 0..100 and a and b to -128..127, each to four decimals; other colors +/// unchanged. +pub(crate) fn as_written(color: Color) -> Color { + let w = |v: f32| ps::written(f64::from(v)) as f32; + match color { + Color::Rgb { r, g, b } => Color::rgb(w(unit(r)), w(unit(g)), w(unit(b))), + Color::Lab { l, a, b } => Color::lab(w(l.clamp(0.0, 100.0)), w(a.clamp(-128.0, 127.0)), w(b.clamp(-128.0, 127.0))), + c => c, + } +} + +/// The RGB values written for `color`, clamped to 0..1: an RGB color's own values (in the working +/// RGB space, as a reader reads them back), any other color's display RGB. +pub(crate) fn rgb_values(color: Color) -> [f32; 3] { + match color { + Color::Rgb { r, g, b } => [r, g, b].map(unit), + c => c.to_rgb().map(unit), + } +} + /// The average colour of a gradient's stops (in RGB). pub(crate) fn average(g: &Gradient) -> Color { let n = g.stops.len().max(1) as f32; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 5a253f21a3..3721ce7960 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -205,6 +205,10 @@ pub struct Structure { pub warped_type: super::envelope::WarpedType, /// The document's graphic styles are those of the file's style list, in place of the defaults. pub styles_read: bool, + /// The patterns that paint the text document's characters, placed in the document, by the + /// name of the art style that gives each: the styles that are one paint, a pattern this read + /// (see `ate::Texts::with_patterns`). + pub text_patterns: HashMap, /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. pub canvas_scale: f64, /// The objects by their XML id (`AI10_ArtUID`), a text object by its slot's id: `None` for an diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ad7f266036..ca943e8817 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1335,6 +1335,17 @@ impl<'a> Reader<'a> { doc.limit_depth_noting(&mut warnings); } warnings.extend(self.names.notes()); + // The patterns of the styles that are one paint, which the text document's characters + // name for their fills and strokes: those that place in the document. + let text_patterns = self + .styles + .iter() + .filter_map(|(style, st)| { + let Some(crate::import::ai::style::StyleItem::Pattern { name, matrix, controls }) = &st.simple_pattern else { return None }; + let paint = super::ops::style_pattern(name, *matrix, controls, &self.pattern_tiles, &self.v7_patterns, &self.global_names, to_doc)?; + Some((style.clone(), paint)) + }) + .collect(); Ok(Structure { doc, to_doc, @@ -1358,6 +1369,7 @@ impl<'a> Reader<'a> { page, }, styles_read, + text_patterns, canvas_scale: self.canvas_scale, xml_ids: self.xml_ids, bindings, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 634c6ae9ca..3a93af7419 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -943,6 +943,29 @@ fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) } +/// A style's pattern (`StyleItem::Pattern`): the file's pattern `name` placed by `matrix` (its +/// space → art space; `controls` apply first for a pattern defined in the version 7 form, `v7` +/// those patterns' names) and carried into the document by `space`, with the name `names` give +/// it there. `None` when the pattern wasn't read (`tiles`: the tiles of those that were, by their +/// names in the file) or doesn't place. +pub(super) fn style_pattern( + name: &str, + matrix: Option<[f64; 6]>, + controls: &[f64], + tiles: &HashMap, + v7: &HashSet, + names: &ShownNames, + space: Affine, +) -> Option { + let tile = tiles.get(name).copied()?; + let mut matrix = matrix.and_then(|m| paint::affine(&m)); + if v7.contains(name) { + matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); + } + let xf = pattern_xf(space, matrix, tile)?; + Some(Paint::Pattern { pattern: names.get(Global::Pattern, name).to_string(), xf }) +} + /// A pattern fill's `x y scaleX scaleY angle reflect axis shear shearAxis` as one transform of the /// pattern's space, applied before its initial matrix: the shear (by `shear` degrees along the line /// at `shearAxis` degrees), the reflection about the line at `axis` degrees when `reflect` isn't @@ -1147,15 +1170,15 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p } StyleItem::Pattern { name, matrix, controls } => match placed.get(&format!("{PATTERN_KEY}{}", paints.names.get(Global::Pattern, name))) { Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), - None => { - let tile = paints.patterns.get(name).copied()?; - let mut matrix = matrix.and_then(|m| paint::affine(&m)); - if paints.v7_patterns.contains(name) { - matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); - } - let xf = pattern_xf(paints.space, matrix, tile)?; - AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: paints.names.get(Global::Pattern, name).to_string(), xf })) - } + None => AppearanceItem::Fill(FillLayer::new(style_pattern( + name, + *matrix, + controls, + paints.patterns, + paints.v7_patterns, + paints.names, + paints.space, + )?)), }, StyleItem::Stroke { brush, .. } => { // A brushed stroke draws as its look only with the brush the Brushes panel keeps diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index ef661c1446..6a75837733 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -202,6 +202,10 @@ pub(super) struct ArtStyle { pub transparency: Option<(f32, BlendMode, bool, Knockout)>, /// [`Self::effects`] holds Effect › Pathfinder effects, which only a group is read with. pub pathfinder: bool, + /// A style that is one paint (`/SimpleStyle :`, its paint's operators, `/Paint ;`), when that + /// paint is a pattern: its [`StyleItem::Pattern`]. A text document's characters name such + /// styles for their fills and strokes (`ate`). `None` for any other paint or style. + pub simple_pattern: Option, } impl ArtStyle { @@ -406,9 +410,14 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap = vec![]; let mut pending: Option = None; let mut in_basic = false; - let mut finish = |name: Option, filters: &mut Vec| { + // A style that is one paint being read, and its pattern so far. + let mut simple: Option> = None; + let mut finish = |name: Option, filters: &mut Vec, simple_pattern: Option| { if let Some(name) = name { - styles.insert(name, style(filters)); + // Only a style that is one paint holds a pattern for type: one with filters as well + // paints with more than that. + let simple_pattern = simple_pattern.filter(|_| filters.is_empty()); + styles.insert(name, ArtStyle { simple_pattern, ..style(filters) }); } filters.clear(); }; @@ -445,11 +454,12 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap match n { b"KnownStyle" => { - finish(name.take(), &mut filters); + finish(name.take(), &mut filters, simple.take().flatten()); operands.clear(); open.clear(); (pending, in_basic, style_entry, other_entry) = (None, false, None, None); } + b"SimpleStyle" if filters.is_empty() => simple = Some(None), b"FillStyle" => style_entry = Some(Kind::Fill), b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other), b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)), @@ -549,9 +559,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap HashMap { - let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v)); - let matrix = vals.iter().rev().find_map(|v| match v { - V::Arr(a) => { - let n: Vec = a.iter().filter_map(V::num).collect(); - <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) - } - _ => None, - }); - let controls: Vec = - vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); - if let Some(name) = name { - f.pattern = Some((name, matrix, controls)); + if let Some(pattern) = pattern_operands(vals, names) { + f.pattern = Some(pattern); } } _ => {} } } +/// The operands of `(name) … [matrix] p`: the pattern's name read by `names`, its matrix (six +/// finite numbers) and the numbers between the name and the matrix. `None` without a name. +fn pattern_operands(vals: &[V], names: &mut Names) -> Option<(String, Option<[f64; 6]>, Vec)> { + let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v))?; + let matrix = vals.iter().rev().find_map(|v| match v { + V::Arr(a) => { + let n: Vec = a.iter().filter_map(V::num).collect(); + <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) + } + _ => None, + }); + let controls: Vec = + vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); + Some((name, matrix, controls)) +} + +/// A paint operator in a style being read: one of the last filter's (`filters`), else one of a style +/// that is one paint (`simple`, while one is read). +fn style_paint_op(filters: &mut [Filter], simple: &mut Option>, op: &str, vals: &[V], names: &mut Names) { + if let Some(f) = filters.last_mut() { + paint_op(f, op, vals, names); + } else if let Some(paint) = simple.as_mut() { + simple_paint_op(paint, op, vals, names); + } +} + +/// A paint operator of a style that is one paint: a pattern (`p`) is kept as its +/// [`StyleItem::Pattern`], and any other paint after it (a colour, a stroke's pattern or a +/// gradient) leaves the style without one. +fn simple_paint_op(paint: &mut Option, op: &str, vals: &[V], names: &mut Names) { + match op { + "p" => *paint = pattern_operands(vals, names).map(|(name, matrix, controls)| StyleItem::Pattern { name, matrix, controls }), + "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" | "xa" | "xx" | "xk" | "xs" + | "xz" | "P" | "Bg" => *paint = None, + _ => {} + } +} + /// A variable-width stroke's profile: its widths (fractions of the stroke's) at positions along the /// path, the same both sides unless it gives each side's. fn width_profile(arrays: &HashMap>) -> Option { @@ -792,6 +828,8 @@ const ANYTHING: Under = Under { strokes: true, patterns: true }; /// The Transform effect `f` (the factor and radian entries; the percent and degree ones are what /// its dialog shows). `None` keeps the drawn look: an entry not read, Random (the file holds no /// seed), or a missing Scale Strokes & Effects or Transform Patterns that would change `under`. +/// The keys are LiveEffect XML's +/// (https://community.adobe.com/questions-652/how-to-define-values-in-action-menu-command-816878). fn transform(f: &Filter, under: Under) -> Option { for key in ["scaleH_Percent", "scaleV_Percent", "rotate_Degrees"] { entry(f, key, Kind::Real).ok()?; @@ -841,6 +879,9 @@ fn transform(f: &Filter, under: Under) -> Option { /// corners are measured in: the distorted box is laid over the object's own bounds, as the catalog /// effect lays it, also when the frame is another size (a unit square, or bounds with the stroke). /// No corners is no change; some but not all, or a frame that isn't an upright rectangle, isn't read. +/// The keys are LiveEffect XML's (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450), +/// with the corners in that order +/// (https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js). fn free_distort(f: &Filter) -> Option { let mut corners = [[[0.0; 2]; 4]; 2]; let mut held = 0; @@ -895,8 +936,10 @@ fn points(f: &Filter) -> Option<&'static str> { Some(if switch(f, "roundness", Kind::Real).ok()?.unwrap_or(false) { "smooth" } else { "corner" }) } -/// The Tweak effect `f` (`Adobe Scribble and Tweak`): relative unless `absoluteness` says otherwise, -/// moving anchors and both handles unless the dictionary turns them off. +/// The Tweak effect `f` (`TWEAK`): relative unless `absoluteness` says otherwise, +/// moving anchors and both handles unless the dictionary turns them off. The keys are LiveEffect +/// XML's: `horz` and `vert` relative, `ahor` and `aver` absolute, and `anch`, `in` and `out` +/// (https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js). fn tweak(f: &Filter) -> Option { let (absolute, h) = sized(f, false, "horz", "ahor")?; let (_, v) = sized(f, false, "vert", "aver")?; @@ -906,7 +949,9 @@ fn tweak(f: &Filter) -> Option { } /// The Zig Zag effect `f`: absolute unless `absoluteness` says otherwise, 4 ridges and Corner points -/// when they are missing. +/// when they are missing. The keys are LiveEffect XML's: `amount` absolute, `relAmount` relative, +/// `ridges` and `roundness` (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450, +/// https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js). fn zig_zag(f: &Filter) -> Option { let (absolute, size) = sized(f, true, "relAmount", "amount")?; let ridges = real(f, "ridges", 4.0, 0.0..=100.0).filter(|r| r.fract() == 0.0)?; @@ -1167,6 +1212,8 @@ fn effect(f: &Filter, under: Under) -> Option { TRANSFORM => ("distort.transform", transform(f, under)?), FREE_DISTORT => ("distort.freeDistort", free_distort(f)?), // Negative puckers (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). + // Pucker & Bloat's `d_factor` and Twist's `angle` are LiveEffect XML's keys + // (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450). PUCKER_BLOAT => ("distort.puckerBloat", json!({"amount": real(f, "d_factor", 0.0, -200.0..=200.0)?})), TWEAK => ("distort.tweak", tweak(f).filter(still)?), // A positive angle turns clockwise in both. diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 47086ca171..c6debd42f9 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1027,8 +1027,7 @@ fn affine_corners_are_a_parallelogram_however_far_they_reach() { #[test] fn roughen_keeps_the_drawn_look() { - // Its outline is random and the file holds no seed; at a size of 0 the effect still redraws the - // path's segments. + // Its outline is random and the file holds no seed. assert_eq!(read_effect("Adobe Roughen", "30 /Real (size) ,\n"), None); assert_eq!(read_effect("Adobe Roughen", ""), None); } @@ -1498,3 +1497,18 @@ fn art_styles_read_registration_and_named_colors() { let [StyleItem::Stroke { paint: Paint::Solid { swatch, tint, .. }, .. }] = st_stroke.items.as_slice() else { panic!("{st_stroke:?}") }; assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0)); } + +/// Only a style that is one paint holds a pattern for type: the same pattern paint in a style that +/// also has filters gives none, whichever comes first. +#[test] +fn only_a_style_of_one_pattern_paint_holds_a_pattern_for_type() { + let simple = "/SimpleStyle :\n0 O\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 1 Xd\n/Paint ;\n"; + let active = "/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n1 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n1 0 0 0 k\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = |body: &str| art_styles_in(format!("/KnownStyle :\n(S) /Name ,\n{body} /Def ;\n").as_bytes(), &mut Names::default()); + assert!(styles(simple).get("S").is_some_and(|s| s.simple_pattern.is_some())); + for body in [format!("{simple}{active}"), format!("{active}{simple}")] { + assert!(styles(&body).get("S").is_none_or(|s| s.simple_pattern.is_none()), "{body}"); + } +} diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 194c0ff1e2..efaed14199 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -21,7 +21,7 @@ //! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. A story without //! a layout has its point type's anchor at its frame's point. See [`Story::place`]. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; @@ -461,6 +461,11 @@ struct Style { v_scale: f64, fill: Option, stroke: Option, + /// The pattern that paints the fill or the stroke in place of its colour: the one the art + /// style named by key 5 of its paint gives ([`Texts::with_patterns`]), placed in the document. + /// `None` for any other paint, which keeps its colour. + fill_pattern: Option, + stroke_pattern: Option, stroke_width: f64, /// Key 61, the stroke's cap: 0 butt, 1 round, 2 projecting (square). stroke_cap: LineCap, @@ -491,9 +496,23 @@ pub(super) struct Texts { /// attributes (key 5) and the paragraph style it is based on (key 6, an index into these). para_sheets: Vec, frames: Vec, + /// The patterns that paint characters, by the name of the art style that gives each + /// ([`Self::with_patterns`]). + patterns: HashMap, } impl Texts { + /// The text document with its characters painted by `patterns`: the patterns of the editing + /// data's art styles that paint with one, each placed in the document, by the style's name. + /// A run's fill or stroke (key 53 or 54) is a paint whose key 0 is a colour and whose key 5 + /// names the art style that paints it. In users' files (used locally, never committed), such a + /// style is one paint: a colour, which key 0 already has, or a pattern, whose key 0 is black + /// (`/0 0 /1 [ 1 0 ]`). The pattern's matrix is in art space, as a path's pattern fill has it: + /// the files' own pages tile the type with the matrix a path's fill would get there. + pub(super) fn with_patterns(self, patterns: HashMap) -> Texts { + Texts { patterns, ..self } + } + /// The text document in the editing copy `data`, if it has one that can be read. pub(super) fn read(data: &[u8]) -> Option { fn find(h: &[u8], n: &[u8], from: usize) -> Option { @@ -540,6 +559,7 @@ impl Texts { char_sheets: sheets("5"), para_sheets: sheets("6"), frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(), + patterns: HashMap::new(), }) } @@ -747,6 +767,8 @@ impl Texts { let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned(); let size = num("1").filter(|v| *v > 0.0 && *v < 1e5).unwrap_or(12.0); + // Key 5 of a paint names the art style that paints it ([`Self::with_patterns`]). + let pattern = |k: &str| pick(k).and_then(|p| p.get("5")).and_then(Val::str).and_then(|name| self.patterns.get(name)).cloned(); Style { font, size, @@ -757,6 +779,8 @@ impl Texts { v_scale: num("7").filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0), fill: if on("56") { Some(pick("53").and_then(paint).unwrap_or(Color::BLACK)) } else { None }, stroke: if matches!(pick("57"), Some(Val::Bool(true))) { pick("54").and_then(paint) } else { None }, + fill_pattern: pattern("53"), + stroke_pattern: pattern("54"), stroke_width: num("63").filter(|v| *v >= 0.0 && *v < 1e4).unwrap_or(1.0), // Numbered as PostScript's `setlinecap` and `setlinejoin` operators number them // (PostScript Language Reference, 3rd ed.): in users' files the page strokes type with @@ -1109,6 +1133,10 @@ impl Story { // The text document measures in the art's units too: the document's scale carries its // sizes and spacings onto the page (a Large Canvas copy is written at 1:10; #2031). let scale = scale * art_scale(to_doc); + // Patterns stay where the document has them, wherever the type's text space is (`xf`'s + // determinant is 1, so it can be inverted; a pattern that still doesn't map to finite + // numbers keeps its colour, see `char_style`). + let to_text = xf.inverse(); let chars: Vec = text.chars().collect(); let mut runs = vec![]; let mut from = 0usize; @@ -1119,13 +1147,13 @@ impl Story { if piece.is_empty() { continue; } - runs.push(TextRun::new(piece, char_style(st, scale))); + runs.push(TextRun::new(piece, char_style(st, scale, to_text))); } if from < chars.len() { let last = self.runs.last().map(|r| &r.1)?; - runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale))); + runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale, to_text))); } - let first = self.runs.first().map(|r| char_style(&r.1, scale))?; + let first = self.runs.first().map(|r| char_style(&r.1, scale, to_text))?; let mut t = TextObject::point(Point::ZERO, "", first); if !runs.is_empty() { t.runs = runs; @@ -1295,10 +1323,20 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { } /// The character style of character attributes `s`: its font from the fonts the app has, its -/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`. -fn char_style(s: &Style, scale: f64) -> CharStyle { +/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`, and its patterns placed +/// in the type's text space by `to_text` (the document → its text space), where they stay as the +/// document has them. +fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + // A fill or stroke that is on takes its pattern in place of its colour. + let paint = |color: Option, pattern: &Option| match (color, pattern) { + // A pattern that can't be placed (the type's text space out of range) keeps the colour. + (Some(_), Some(Paint::Pattern { pattern, xf })) if (to_text * *xf).is_finite() => { + Paint::Pattern { pattern: pattern.clone(), xf: to_text * *xf } + } + (color, _) => color.map_or(Paint::None, Paint::solid), + }; // Type without a stroke keeps the stroke's defaults, as its weight is 0. let (stroke_cap, stroke_join, stroke_miter_limit) = if s.stroke.is_some() { (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) } else { (LineCap::Butt, LineJoin::Miter, 10.0) }; @@ -1314,8 +1352,8 @@ fn char_style(s: &Style, scale: f64) -> CharStyle { baseline_shift: s.baseline_shift * scale, h_scale: s.h_scale * 100.0, v_scale: s.v_scale * 100.0, - fill: s.fill.map_or(Paint::None, Paint::solid), - stroke: s.stroke.map_or(Paint::None, Paint::solid), + fill: paint(s.fill, &s.fill_pattern), + stroke: paint(s.stroke, &s.stroke_pattern), stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 }, stroke_cap, stroke_join, @@ -1350,6 +1388,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let justify = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justify; // Right to left from the document's defaults, or the paragraph's own. @@ -1373,6 +1412,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading; assert_eq!(auto(b""), JustificationOptions::default().auto_leading); @@ -1395,6 +1435,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation; let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true }; @@ -1412,6 +1453,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation; assert_eq!(own(b""), d); @@ -1431,6 +1473,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let tabs = |own: &[u8], scale: f64| { para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale) @@ -1497,6 +1540,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), } .para(None); let mut s = story(" 80 mm ", &[0.0], ¶); @@ -1544,6 +1588,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: cases.iter().map(|c| frame(c)).collect(), + patterns: HashMap::new(), }; let vertical: Vec = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect(); assert_eq!(vertical, [true, false, false, false, false, false, false, false, false, false, false]); @@ -1561,6 +1606,7 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Block) /6 << /35 3 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let tcy = |run: &[u8]| texts.style(Some(&read(run).unwrap())).tate_chu_yoko; assert!(tcy(b"/6 << /35 3 >>"), "the run's own"); @@ -1584,6 +1630,7 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rotated) /6 << /35 2 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let roman = |defaults: &[u8], run: &[u8]| texts(defaults).style(Some(&read(run).unwrap())).standard_vertical_roman; assert!(roman(b"/35 1", b"/6 << >>"), "the document's defaults"); @@ -1614,9 +1661,10 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rounded) /6 << /61 1 /62 1 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let stroke = |run: &[u8]| { - let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0); + let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0, Affine::IDENTITY); (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) }; assert_eq!(stroke(b"/6 << /61 2 /62 2 /64 2.5 >>"), (LineCap::Square, LineJoin::Bevel, 2.5), "the run's own"); @@ -1638,6 +1686,88 @@ mod tests { assert_eq!(stroke(b"/5 1 /6 << /57 false >>"), (LineCap::Butt, LineJoin::Miter, 10.0), "no stroke"); } + /// The pattern "Dots", its tile's corner at `at`. + fn dots_at(at: (f64, f64)) -> Paint { + Paint::Pattern { pattern: "Dots".into(), xf: Affine::translate(at) } + } + + /// Texts whose characters may name the art style "Anon 1", which paints with "Dots" at (10, 20) + /// in the document, and "Solid", which isn't a pattern. + fn patterned_texts() -> Texts { + let patterns = HashMap::from([("Anon 1".to_string(), dots_at((10.0, 20.0))), ("Solid".to_string(), Paint::solid(Color::WHITE))]); + Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + patterns: HashMap::new(), + } + .with_patterns(patterns) + } + + /// Key 5 of a run's fill or stroke names the art style that paints it: one that paints with a + /// pattern gives the run that pattern in place of the colour of key 0 (black), placed in the + /// type's text space where the document has it. A fill or stroke that is off stays off, and a + /// key 5 that isn't the name of such a style, or isn't a name at all, keeps the colour. + #[test] + fn a_run_painted_by_a_patterns_art_style_takes_the_pattern() { + // GUARDS: type a pattern paints came in black, the colour key 0 keeps for it. + let texts = patterned_texts(); + let black_paint = Paint::solid(Color::gray(1.0)); + let to_text = Affine::translate((-4.0, -5.0)); + let paints = |run: &str| { + let s = char_style(&texts.style(Some(&read(run.as_bytes()).unwrap())), 1.0, to_text); + (s.fill, s.stroke) + }; + let black = |style: &str| format!("<< /99 /CAITextPaint /0 << /0 0 /1 [ 1.0 0.0 ] >> {style} >>"); + let fill = |style: &str| paints(&format!("/6 << /53 {} >>", black(style))); + assert_eq!(fill("/5 (Anon 1)"), (dots_at((6.0, 15.0)), Paint::None)); + let stroke = paints(&format!("/6 << /53 {} /57 true /54 {} >>", black(""), black("/5 (Anon 1)"))); + assert_eq!(stroke, (black_paint.clone(), dots_at((6.0, 15.0)))); + // Off. + assert_eq!(paints(&format!("/6 << /56 false /53 {} >>", black("/5 (Anon 1)"))), (Paint::None, Paint::None)); + assert_eq!(paints(&format!("/6 << /57 false /54 {} >>", black("/5 (Anon 1)"))).1, Paint::None); + // A stroke whose colour can't be read paints nothing, pattern or not, as before. + assert_eq!(paints("/6 << /57 true /54 << /0 << /0 9 /1 [ 1.0 ] >> /5 (Anon 1) >> >>").1, Paint::None); + for other in ["", "/5 (Anon 2)", "/5 (Solid)", "/5 ()", "/5 /Anon", "/5 1", "/5 [ (Anon 1) ]", "/5 << /0 (Anon 1) >>", "/5 true"] { + assert_eq!(fill(other), (black_paint.clone(), Paint::None), "{other}"); + } + // A text space the pattern can't be placed in keeps the colour. + let run = read(format!("/6 << /53 {} >>", black("/5 (Anon 1)")).as_bytes()).unwrap(); + for to_text in [Affine::new([1e308, 0.0, 0.0, 1e308, 0.0, 0.0]), Affine::new([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0])] { + assert_eq!(char_style(&texts.style(Some(&run)), 1.0, to_text).fill, black_paint.clone(), "{to_text:?}"); + } + } + + /// A run's pattern stays where the document has it whatever the type's own text space: turned + /// and moved point type, and area type, whose text space is the document's. + #[test] + fn a_runs_pattern_stays_where_the_document_has_it() { + let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + for frame in [ + FrameKind::Point([0.0, 1.0, -1.0, 0.0, 30.0, 40.0]), + FrameKind::Point([2.0, 0.0, 0.0, 2.0, -7.0, 3.0]), + FrameKind::Area(area_segments(), AreaFrame::default()), + ] { + let mut st = story("type", &[0.0], &Para::default()); + st.frames = vec![frame.clone()]; + st.runs[0].1.fill_pattern = Some(dots_at((10.0, 20.0))); + st.runs[0].1.stroke = Some(Color::BLACK); + st.runs[0].1.stroke_pattern = Some(dots_at((-3.0, 8.0))); + let node = st.node(NodeId(1), 0, (0.0, 0.0), to_doc).unwrap(); + let NodeKind::Text(t) = node.kind else { panic!("not type") }; + for (paint, at) in [(&t.runs[0].style.fill, Point::new(10.0, 20.0)), (&t.runs[0].style.stroke, Point::new(-3.0, 8.0))] { + let Paint::Pattern { pattern, xf } = paint else { panic!("{paint:?}") }; + assert_eq!(pattern, "Dots"); + let corner = t.xf * *xf * Point::ZERO; + assert!((corner - at).hypot() < 1e-9, "{frame:?}: {corner:?}"); + } + } + } + /// A right-to-left paragraph is laid out right to left even when it has no right-to-left /// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one /// that doesn't say, goes by its letters. @@ -1651,6 +1781,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let direction = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).direction; assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults"); @@ -1698,6 +1829,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], + patterns: HashMap::new(), }; texts.frame(0) } @@ -1904,6 +2036,8 @@ mod tests { v_scale: 1.0, fill: Some(Color::BLACK), stroke: None, + fill_pattern: None, + stroke_pattern: None, stroke_width: 1.0, stroke_cap: LineCap::Butt, stroke_join: LineJoin::Miter, @@ -2352,6 +2486,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], + patterns: HashMap::new(), }; texts.frame(0).unwrap() } diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 46fd148db9..3ff736d1ff 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -24,6 +24,8 @@ mod state; mod strips; pub(crate) use paint::collapse; +#[cfg(test)] +pub(crate) use paint::tilings_compared; pub(crate) use state::{GState, Out, Space, process}; pub(crate) use strips::merge_strips; diff --git a/crates/eps/src/import/graphics/paint.rs b/crates/eps/src/import/graphics/paint.rs index fc2490fb41..156b6e421e 100644 --- a/crates/eps/src/import/graphics/paint.rs +++ b/crates/eps/src/import/graphics/paint.rs @@ -5,6 +5,19 @@ use super::{ PsError, REGISTRATION, Rc, Rect, Res, SHADING_SAMPLES, Shared, UNKNOWN_FUNCTIONS, UNKNOWN_SHADINGS, Vec2, clamp_between, ps_err, }; +#[cfg(test)] +thread_local! { + /// How many patterns the lookups of tiling patterns on this thread compared with the pattern + /// they looked for. + static TILINGS_COMPARED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// [`TILINGS_COMPARED`] of this thread. +#[cfg(test)] +pub(crate) fn tilings_compared() -> u64 { + TILINGS_COMPARED.get() +} + impl Interp<'_> { // ---------- colour ---------- @@ -212,7 +225,13 @@ impl Interp<'_> { let proc = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; let key = proc.items().cloned().ok_or(PsError::Ps("typecheck", "PaintProc".into()))?; let hash = tiling_hash(&key, tile, under.as_ref()); - let made = self.out.tilings.get(&hash).and_then(|list| list.iter().find(|(p, t, u, _)| p.same(&key) && *t == tile && *u == under)); + let made = self.out.tilings.get(&hash).and_then(|list| { + list.iter().find(|(p, t, u, _)| { + #[cfg(test)] + TILINGS_COMPARED.set(TILINGS_COMPARED.get().saturating_add(1)); + p.same(&key) && *t == tile && *u == under + }) + }); if let Some((.., name)) = made { return Ok(Paint::Pattern { pattern: name.clone(), xf }); } diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 6b96aabc25..0ea7017c11 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -98,6 +98,9 @@ pub(crate) struct Interp<'a> { /// The font a `kshow` procedure sees as current while type in a CIDFont only named is set in /// a font of its own (see `text`). pub kshow_font: Option, + /// The `cshow` procedure running over type in a CIDFont only named, with the font it sees as + /// current (see `text`). + pub cshow: Option, pub g: GState, pub saved: Vec, pub out: Out, @@ -240,6 +243,7 @@ impl<'a> Interp<'a> { cmap_font: 0, predefined_cmaps: vec![], kshow_font: None, + cshow: None, type42_counted: HashSet::new(), g, saved: vec![], diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c86cdb9516..8adcfccef0 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -191,6 +191,36 @@ fn left_out_of_comparisons(page: Page, mut restyled: Vec, placeholders: Ve restyled } +/// A single PDF page capped at the format's 14,400-unit limit, without user-space scaling. +/// The other dimension must still match; unrelated page-size mismatches remain errors. +fn capped_pdf_page(doc: &Document, visible: &Document, board: Rect, page: Rect, unit: f64) -> bool { + let capped = |side: f64, pdf: f64| (pdf - 14_400.0).abs() <= 0.5 && side > pdf + 0.5; + let matching = |side: f64, pdf: f64| (side - pdf).abs() <= 0.5 || capped(side, pdf); + unit == 1.0 + && doc.artboards.len() == 1 + && visible.artboards.len() == 1 + && (capped(board.width(), page.width()) || capped(board.height(), page.height())) + && matching(board.width(), page.width()) + && matching(board.height(), page.height()) +} +/// The PDF-covered area of a trusted oversized artboard; its full artboard stays in the document. +fn compared_page_rect( + doc: &Document, + visible: &Document, + board: Rect, + pdf: Rect, + trusted_page: Option, + unit: f64, + warnings: &mut Vec, +) -> Rect { + if matches!(trusted_page, Some(Page::Artboard)) && capped_pdf_page(doc, visible, board, pdf, unit) { + warnings.push("the editing data's artboard is larger than the PDF page: the full artboard comes from its editing data".into()); + Rect::new(board.x0, board.y0, board.x0 + pdf.width(), board.y0 + pdf.height()) + } else { + board + } +} + /// [`build`] from `structure`, the editing copy `data` already read. pub(super) fn build_from( structure: Structure, @@ -217,12 +247,13 @@ pub(super) fn build_from( placed_pages, envelopes_drawn, styles_read, + text_patterns, canvas_scale, xml_ids, bindings, bindings_skipped, warped_type, - warnings, + mut warnings, } = structure; let mut restyled = left_out_of_comparisons(page, restyled, placeholders); // Editing data in the units of the page's user space ([`in_units`]), or at its declared large @@ -270,6 +301,7 @@ pub(super) fn build_from( { (rect, cropped) = (art, true); } + rect = compared_page_rect(&doc, visible, rect, page_rect, trust.then_some(page), unit, &mut warnings); if !fits(&rect) { // Both sizes in points as the fields show them, and the editing data's page to the // file's as a scale (1:10 is a tenth of its size) when its sides are in proportion and @@ -374,7 +406,7 @@ pub(super) fn build_from( // The page's type that needs no text object of the layers: the layers' own legacy text, or // placed files that show it. let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?; - let texts = Texts::read(data); + let texts = Texts::read(data).map(|t| t.with_patterns(text_patterns)); // Each story is read once, however many text objects name it. let mut read: std::collections::BTreeMap>> = Default::default(); let stories: Vec>> = infos diff --git a/crates/eps/src/import/predefined_cmap.rs b/crates/eps/src/import/predefined_cmap.rs index 19525fdf36..c688ebf9ae 100644 --- a/crates/eps/src/import/predefined_cmap.rs +++ b/crates/eps/src/import/predefined_cmap.rs @@ -288,7 +288,7 @@ fn code_space(cmap: &str) -> Option { "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, _ if stem.ends_with("RKSJ") => RKSJ, "EUC" | "78-EUC" => EUC_JP, - "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, + "" | "78" | "Add" | "Ext" | "NWP" | "GBT" | "CNS2" => ROW_CELL, "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, "GBpc-EUC" | "GBTpc-EUC" => GB_PC, "GBK-EUC" | "GBKp-EUC" => GBK, @@ -327,8 +327,9 @@ pub(super) fn known(cmap: &str) -> bool { /// The codes of string `bytes` in predefined CMap `cmap` when its mappings can't be read, each /// standing for no character: as long as the code space range it falls in ([`code_space`]; a byte -/// that starts no code is a code of its own), or two bytes when the code space isn't listed (the -/// row-and-cell codes of `GBT-H` and `CNS2-H`). A 0x0D or 0x09 byte is always a code of its own. +/// that starts no code is a code of its own, as in the row-and-cell code space of `GBT-H` and +/// `CNS2-H`), or two bytes when the code space isn't listed. A 0x0D or 0x09 byte is always a code +/// of its own. pub(super) fn unread_codes(cmap: &str, bytes: &[u8]) -> Decoded { let space = code_space(cmap); let mut out = Decoded::default(); diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 6a1df810bc..4709a3c5bf 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -7,6 +7,9 @@ use vectorcraft_color::{Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, ColorMode, Document, LineCap, LineJoin, Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; +use crate::import::graphics::{GState, Out, tilings_compared}; +use crate::import::interp::Interp; +use crate::import::obj::Obj; use crate::import::{Dsc, import}; use crate::{EpsOptions, Level, Preview, Raster}; @@ -369,12 +372,19 @@ fn dash_patterns_hold_at_most_65535_entries() { /// Saved and copied graphics states share the dash pattern, and `setdash`, `currentdash` and /// each stroke count the entries they read or copy against the budgets. Under a pattern of -/// 65,535 entries, 4,000,000 passes of `gsave grestore` and 2,000,000 passes of -/// `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and `strokepath` -/// run out of a budget. +/// 65,535 entries, the states that `gsave`, `grestore`, `gstate`, `currentgstate` and `setgstate` +/// save, make or set hold that one pattern, 4,000,000 passes of `gsave grestore` and 2,000,000 +/// passes of `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and +/// `strokepath` run out of a budget. #[test] fn work_on_the_longest_dash_pattern_ends_soon() { let dash = "0 0 10 10 rectfill /a 65535 array def 0 1 65534 { a exch 1 put } for a 0 setdash"; + let shared = run_looking(&format!("{dash} gsave gsave grestore gstate gstate currentgstate dup setgstate"), |it| { + let Some(saved) = it.saved.last().map(|g| g.dash.clone()) else { return false }; + let made: Vec<_> = it.stack.iter().filter_map(|o| if let Obj::GState(g) = o { Some(g.dash.clone()) } else { None }).collect(); + saved.len() == 65_535 && made.len() == 2 && made.iter().chain([&it.g.dash]).all(|m| std::rc::Rc::ptr_eq(m, &saved)) + }); + assert!(shared); for body in ["4000000 { gsave grestore } repeat", "2000000 { gstate currentgstate setgstate } repeat"] { let r = read_soon(&format!("{dash} {body} 20 20 10 10 rectfill")); assert_eq!(objects(&r.document).len(), 2, "{body}: {:?}", r.warnings); @@ -530,13 +540,15 @@ fn non_finite_arithmetic_is_undefinedresult() { assert!(!objects(&r.document).is_empty(), "{:?}", r.warnings); } -/// Tiling patterns and spot inks are found again through a hash of what tells them apart, so a -/// program that makes 200,000 patterns and 200,001 inks reads within [`read_soon`]'s 20 seconds. -/// Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A pattern made -/// again from its prototype is the same pattern, and so is an uncolored pattern set again in a -/// color it was set in before. +/// Tiling patterns are found again through a hash of what tells them apart. A program that sets +/// 200,000 different patterns and also sets 200,001 spot inks reads them all, and its 200,000 +/// pattern lookups compare at most 200,000 earlier patterns in all; going through every pattern +/// made so far would compare about 2 × 10^10. The test does not count or time the spot ink +/// lookups. Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A +/// pattern made again from its prototype is the same pattern, and so is an uncolored pattern set +/// again in a color it was set in before. #[test] -fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { +fn many_tiling_patterns_are_found_again_through_a_hash() { let r = read( "/pp { pop 0 0 1 1 rectfill } def \ /a << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 2 /YStep 2 /PaintProc /pp load >> def \ @@ -560,7 +572,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { assert!(names.iter().all(|n| d.swatch(n).is_some_and(|s| matches!(s.paint, Paint::Pattern { .. })))); // 100,000 patterns whose cells differ, an uncolored pattern in 100,000 tints of an ink that is // white at every tint (only the tint tells those patterns apart), and 200,000 more inks. - let r = read_soon( + let (r, compared) = read_counting_tilings( "0 1 99999 { /i exch def << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep i 2 add /YStep 1 \ /PaintProc { pop 0 0 1 1 rectfill } >> matrix makepattern setpattern } for \ [/Separation (Spot) /DeviceGray { pop 1 }] setcolorspace \ @@ -568,6 +580,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { matrix makepattern def 0 1 99999 { 100000 div p setpattern } for \ 0 1 199999 { /i exch def [/Separation i 8 string cvs /DeviceGray {}] setcolorspace } for 0 0 1 1 rectfill", ); + assert!(compared <= 200_000, "{compared} patterns compared"); let d = &r.document; assert_eq!(d.patterns.len(), 200_000); assert!(d.patterns.iter().enumerate().all(|(i, p)| p.name == format!("Pattern {}", i + 1))); @@ -1112,19 +1125,51 @@ fn hostile_executable_files_and_type_loops_are_refused() { blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor"); } -/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes -/// more than 20 s. +/// How long [`read_soon`] and [`read_counting_tilings`] wait, in seconds: a guard against a read +/// that never ends. The budgets, limits and counts each test names decide what it checks. +const NEVER_ENDS: u64 = 300; + +/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading has not +/// finished after [`NEVER_ENDS`] seconds. fn read_soon(body: &str) -> crate::Imported { - read_within(body, 20) + read_within(body, NEVER_ENDS) } /// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes /// more than `secs` seconds. fn read_within(body: &str, secs: u64) -> crate::Imported { + read_counting(body, secs).0 +} + +/// [`read_soon`], with how many patterns the tiling pattern lookups compared while reading. +fn read_counting_tilings(body: &str) -> (crate::Imported, u64) { + read_counting(body, NEVER_ENDS) +} + +/// [`read_within`], with how many patterns the tiling pattern lookups compared while reading. +fn read_counting(body: &str, secs: u64) -> (crate::Imported, u64) { let (tx, rx) = std::sync::mpsc::channel(); let ps = eps(body); - std::thread::spawn(move || tx.send(import(&ps)).ok()); - rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap() + std::thread::spawn(move || tx.send((import(&ps), tilings_compared())).ok()); + let (read, compared) = rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")); + (read.unwrap(), compared) +} + +/// Runs the program of `body` in an interpreter of its own on the 100 × 100 page of [`eps`]'s +/// bounding box, and returns what `look` finds in the interpreter when the program has ended. It +/// does not read the file's comments or make a document. The test fails when the program stops on +/// an error. +fn run_looking(body: &str, look: fn(&Interp) -> T) -> T { + let file = eps(body); + let ps = super::program(&file).unwrap(); + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + let page = vectorcraft_geom::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + let mut it = Interp::new(ps, GState::default(), Out::new(doc, page, Rect::new(0.0, 0.0, 100.0, 100.0))); + assert!(it.run().is_ok(), "running {body} failed"); + let found = look(&it); + it.release(); + found } /// Reads `body` after a square: the square stays, and the program runs out of the operation diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 25ed81c2b0..8350cd514b 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -487,21 +487,29 @@ fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() { measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt")); measured(&format!("{set} 10 20 moveto <93fa967b> [10 10] yshow currentpoint 40 sub abs 0.01 lt exch 10 sub abs 0.01 lt and")); measured(&format!("{set} 10 20 moveto <93fa967b> [30 10 30 10] xyshow currentpoint 40 sub abs 0.01 lt exch 70 sub abs 0.01 lt and")); - // `kshow` runs its procedure between each two codes: it moves the point 100 more here, and a - // font it sets stays current. + // `kshow` runs its procedure between each two codes: it moves the point 100 more here. The + // current font is restored when the procedure completes (PLRM 3rd ed., `kshow`), so a font it + // sets doesn't stay current, here or for a font found by name. measured(&format!( "{set} 10 20 moveto <93fa967b> show currentpoint 10 20 moveto {{pop pop 100 0 rmoveto}} <93fa967b> kshow currentpoint \ 3 -1 roll sub abs 0.01 lt 3 1 roll sub -100 sub abs 0.01 lt and" )); measured(&format!( - "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont}} <93fa967b> kshow currentfont /FontName get /Helvetica eq" + "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont}} <93fa967b> kshow currentfont /FontName get /Ryumin-Light-90ms-RKSJ-H eq" )); - // The procedure sees the font the file set as current, and the next one the font an earlier - // one set. + measured( + "/Helvetica findfont 20 scalefont setfont 10 20 moveto {pop pop /Courier findfont 10 scalefont setfont} (AB) kshow currentfont /FontName get /Helvetica eq", + ); + // Each procedure sees the font the file set as current, also after an earlier one set another. measured(&format!("{set} 10 20 moveto {{pop pop currentfont /FontName get /seen exch def}} <93fa967b> kshow seen /Ryumin-Light-90ms-RKSJ-H eq")); measured(&format!( "{set} 10 20 moveto /n 0 def {{pop pop /n n 1 add def n 1 eq {{/Helvetica findfont 10 scalefont setfont}} {{currentfont /FontName get /seen exch def}} ifelse}} \ - <93fa967b41> kshow seen /Helvetica eq" + <93fa967b41> kshow seen /Ryumin-Light-90ms-RKSJ-H eq" + )); + // A `kshow` the procedure runs in a font it set sees that font. + measured(&format!( + "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont {{pop pop currentfont /FontName get /seen exch def}} (AB) kshow}} \ + <93fa967b> kshow seen /Helvetica eq" )); // The procedure gets the two codes' values (left on the stack here). measured(&format!( @@ -575,8 +583,9 @@ fn named_cid_type_is_set_alike_by_every_show_operator() { /// `charpath`, `cshow` and `glyphshow` in a CID-keyed font over a predefined CMap, its CIDFont only /// named: `charpath` adds the outlines of the characters the codes stand for and moves the point -/// as `show` does; `cshow`'s procedure gets each code (two bytes for a two-byte code) with its -/// width; `glyphshow` sets the named character itself. +/// as `show` does; `cshow`'s procedure gets each code's last byte with its width and runs with the +/// CIDFont current, a show in it setting the code's character; `glyphshow` sets the named +/// character itself. #[test] fn named_cid_type_in_charpath_cshow_and_glyphshow() { let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont"; @@ -595,9 +604,35 @@ fn named_cid_type_in_charpath_cshow_and_glyphshow() { top("fd41"), top("41") )); - // The procedure leaves each code: 日 (0x93FA), then A. - check(&format!("{set} [ {{pop pop}} <93fa41> cshow ] dup length 2 eq exch dup 0 get 16#93fa eq exch 1 get 65 eq and and")); + // The procedure leaves each code's last byte (PLRM 3rd ed., `cshow`): 日 (0x93FA), then A. + check(&format!("{set} [ {{pop pop}} <93fa41> cshow ] dup length 2 eq exch dup 0 get 16#fa eq exch 1 get 65 eq and and")); check(&format!("{set} 0 {{pop exch pop add}} <93fa967b41> cshow <93fa967b41> stringwidth pop sub abs 0.01 lt")); + // While the procedure runs, the current font is the CIDFont and `rootfont` gives the composite + // font (PLRM 3rd ed., `cshow` and `rootfont`); the current font is restored after each run, + // also when the procedure sets another. + check(&format!( + "{set} {{pop pop pop currentfont dup /FontType get /t exch def /CIDFontName get /seen exch def}} <93fa> cshow \ + seen /Ryumin-Light eq t 9 eq and" + )); + check(&format!("{set} {{pop pop pop rootfont /FontName get /seen exch def}} <93fa> cshow seen /Ryumin-Light-90ms-RKSJ-H eq")); + check(&format!( + "{set} /n 0 def {{pop pop pop /n n 1 add def n 1 eq {{/Helvetica findfont 10 scalefont setfont rootfont /FontName get /r exch def}} \ + {{currentfont /CIDFontName get /seen exch def}} ifelse}} <93fa41> cshow seen /Ryumin-Light eq r /Helvetica eq and \ + currentfont /FontName get /Ryumin-Light-90ms-RKSJ-H eq and" + )); + // A show in the CIDFont shows the character of the code the procedure was given, the string + // holding its last byte, and moves the point as far as `show` does. Any other string, an + // empty one included, is a `rangecheck` error. + for s in ["()", "(AB)", "<41>"] { + check(&format!("gsave nulldevice {set} 10 20 moveto {{{{pop pop pop {s} show}} <93fa> cshow}} stopped grestore")); + } + check(&format!("gsave nulldevice {set} 10 20 moveto {{{{pop pop pop show}} <93fa> cshow}} stopped not grestore")); + let r = super::tests::read(&format!("{set} 10 20 moveto {{pop pop ( ) dup 0 4 -1 roll put show}} <93fa967b> cshow")); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "日本"); + check(&format!( + "gsave nulldevice {set} 10 20 moveto <93fa967b> show currentpoint 10 20 moveto {{pop pop ( ) dup 0 4 -1 roll put show}} <93fa967b> cshow \ + currentpoint grestore 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and" + )); let r = super::tests::read(&format!("{set} 10 20 moveto /uni00E9 glyphshow")); assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["é"]); } @@ -640,6 +675,8 @@ fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { "/Ryumin-Light-GBT-V findfont 20 scalefont setfont 10 100 moveto <21212121> show currentpoint 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and", // A tab is a code of its own: three codes. "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <09092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", + // A byte that starts no row-and-cell code is a code of its own, also before a tab: three. + "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <21092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", ] { let r = super::tests::read(&format!("{cond} {{ 0 0 10 10 rectfill }} if")); assert!(all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "{cond}"); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 7353c5c190..687834c97b 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3366,6 +3366,71 @@ fn stroked_type_keeps_its_cap_join_and_miter_limit() { ); } +#[test] +fn type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names() { + // GUARDS: type filled or stroked with a pattern swatch came in black: the text document keeps + // black as the paint's colour, and the pattern is in the art style that key 5 of the paint + // names, a style of one paint (`/SimpleStyle`) whose matrix is in art space. + use vectorcraft_color::{Color, Paint}; + use vectorcraft_geom::Point; + let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n"; + let simple = |name: &str, paint: &str| { + format!( + "/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 1 Xd\n/Paint ;\n /Def ;\n" + ) + }; + let pattern = |m: &str| format!("(Dots) 0 0 1 1 0 0 0 0 0 [{m}] p"); + let styles = [ + simple("Anon 1", &pattern("1 0 0 1 0 0")), + simple("Anon 2", &pattern("2 0 0 2 10 20")), + // Styles that give no pattern: one the file doesn't define, a matrix that doesn't place, no + // name, a colour after the pattern, a colour, a gradient. + simple("Anon 3", "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), + simple("Anon 4", &pattern("0 0 0 0 0 0")), + simple("Anon 5", "0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), + simple("Anon 6", &format!("{}\n0 0 0 1 k", pattern("1 0 0 1 0 0"))), + simple("Anon 7", "0 0 0 1 k"), + simple("Anon 8", "0 Bb\n2 (Rainbow) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB"), + ] + .concat(); + let styles = format!("%AI9_BeginArtStyles\n{styles}%AI9_EndArtStyles\n"); + let doc = text_document(&[("abcdefghijklmnopq\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let one = "<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 18 >>"; + assert!(doc.contains(one)); + let black = |style: &str| format!("<< /99 /CAITextPaint /0 << /0 0 /1 [ 1.0 0.0 ] >> /5 ({style}) >>"); + let run = |own: &str| format!("<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 {own} >> >> >> /1 2 >>"); + let mut runs = vec![run(&format!("/53 {}", black("Anon 1"))), run(&format!("/53 {} /57 true /54 {} /63 2.0", black("Anon 7"), black("Anon 2")))]; + runs.extend((3..=8).map(|n| run(&format!("/53 {}", black(&format!("Anon {n}")))))); + // A style the file doesn't have, over "q" and the story's end. + runs.push(run(&format!("/53 {}", black("Anon 9")))); + let doc = doc.replacen(one, &runs.join(" "), 1); + let art = native_with_text(&format!("{dots}{styles}{}{}", layer("Back", true, &square(10, 10)), layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let t = texts[0]; + // Each character's fill and stroke, a pattern as where its tile's top left corner is. + let paint = |p: &Paint| match p { + Paint::Pattern { pattern, xf } => { + let at = t.xf * *xf * Point::ZERO; + format!("{pattern} at {:.3} {:.3}", at.x, at.y) + } + Paint::Solid { color, .. } if *color == Color::gray(1.0) => "black".into(), + Paint::None => "none".into(), + other => format!("{other:?}"), + }; + let chars: Vec<(char, String, String)> = + t.runs.iter().flat_map(|r| r.text.chars().map(|c| (c, paint(&r.style.fill), paint(&r.style.stroke))).collect::>()).collect(); + let want = |c: char, fill: &str, stroke: &str| (c, fill.to_string(), stroke.to_string()); + // The tile's top left (0, 4) in art space, on a page 100 high: (0, 96). Scaled twice and moved + // by (10, 20): (10, 28) in art space, (10, 72) in the document. + let mut expected = vec![want('a', "Dots at 0.000 96.000", "none"), want('b', "Dots at 0.000 96.000", "none")]; + expected.extend(['c', 'd'].map(|c| want(c, "black", "Dots at 10.000 72.000"))); + expected.extend("efghijklmnopq".chars().map(|c| want(c, "black", "none"))); + assert_eq!(chars, expected); + assert_eq!(r.document.pattern("Dots").map(|p| p.tile.height()), Some(4.0)); +} + #[test] fn orientation_and_tate_chu_yoko_values_it_cant_use_are_ignored() { for bad in ["(2)", "2.5", "1e300", "-2", "1", "true", "[ 2 ]"] { @@ -6940,3 +7005,21 @@ fn text_a_placed_file_covers_only_at_its_first_placement_is_kept() { assert_eq!(text_of(&d.layers[0]).iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); assert!(!w.iter().any(|w| w.contains("doesn't draw")), "{w:?}"); } + +/// #6865: explicitly trusting editing data keeps an artboard larger than its PDF page. +#[test] +fn trusted_editing_data_keeps_an_artboard_taller_than_its_pdf_page() { + let art = native(&layer("Oversized", true, &square(10, 10))).replace(" 0 0 100 100", " 0 0 12992.726 14637.147"); + let (d, w, read) = crate::layered_ai(&private(&art), Document::new(12992.7, 14400.0), vec![], false, true, 1.0); + assert!(read, "{w:?}"); + assert_eq!(names(&d), ["Oversized"], "{w:?}"); + assert!((d.artboards[0].rect.height() - 14637.147).abs() < 0.001); + assert_eq!(kinds(&d.layers[0]), ["path"]); + assert!(w.iter().any(|w| w.contains("artboard is larger than the PDF page")), "{w:?}"); + let child = &d.layers[0].children().unwrap()[0]; + assert!(child.visual_bounds().unwrap().y0 > 14400.0, "art beyond the PDF page is kept"); + let (_, w, read) = crate::layered_ai(&private(&art), Document::new(12992.7, 14400.0), vec![], false, false, 1.0); + assert!(!read, "default import still rejects the size mismatch: {w:?}"); + let (_, w, read) = crate::layered_ai(&private(&art), Document::new(12000.0, 14400.0), vec![], false, true, 1.0); + assert!(!read, "the uncapped dimension must still match: {w:?}"); +} diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 73fc4776d0..989450f440 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -186,22 +186,15 @@ impl Interp<'_> { // The procedure shows or places each character: it gets its code and width. let s = self.pop_str()?.to_vec(); let proc = self.pop_proc()?; - // In a CIDFont only named, a code may be several bytes: the procedure gets each - // code's value. + // In a CIDFont only named, a code may be several bytes: the procedure runs once for + // each code. if let Some(named) = self.named_cid_type(&s)? { - let mut chars = named.codes.text.chars(); - for &(code, n) in &named.codes.codes { - let mut one = Decoded::default(); - one.push(code, chars.by_ref().take(n)); - let v = self.named_cid_width(&named, &one)?; - self.push(Obj::Int(i64::from(code)))?; - self.push_num(v.x)?; - self.push_num(v.y)?; - if !self.body(&proc)? { - break; - } - } - return Ok(()); + let root = self.g.font.clone(); + let before = self.cshow.take(); + let r = self.named_cshow(named, &proc, root.clone()); + self.cshow = before; + self.g.font = root; + return r; } for b in s { let v = self.width_of(&[b])?; @@ -252,7 +245,12 @@ impl Interp<'_> { SetCharWidth => self.glyph_width = Some(self.nums::<2>()?), SetCacheDevice2 => self.glyph_width = Some(self.nums::<10>()?[..2].try_into().unwrap_or_default()), RootFont => { - let font = match self.g.font.clone() { + // Inside a `cshow` procedure the composite font, unless the procedure set a font. + let root = match (&self.cshow, &self.g.font) { + (Some(c), Some(f)) if f.same(&c.selected) => Some(c.root.clone()), + _ => None, + }; + let font = match root.or_else(|| self.g.font.clone()) { Some(f) => f, None => self.find_font(&Obj::name(DEFAULT_FONT))?, }; @@ -791,18 +789,16 @@ impl Interp<'_> { self.push(Obj::Int(code.into()))?; self.push(Obj::Int(next.into()))?; // The procedure sees the font the file set, not the one type in a CIDFont only named is - // set in; a font it sets stays current. - let Some(outer) = self.kshow_font.take() else { - self.call(p.clone())?; - return Ok(self.user_point()? - pen); + // set in, and the current font is restored when it completes (PLRM 3rd ed., `kshow`). + // A `kshow` the procedure runs sees the font the procedure set. + let outer = self.kshow_font.take(); + let inner = match outer.clone() { + Some(outer) => self.g.font.replace(outer), + None => self.g.font.clone(), }; - let inner = self.g.font.replace(outer.clone()); let r = self.call(p.clone()); - // Once the procedure sets a font, it and the later procedures see that one. - if self.g.font.as_ref().is_some_and(|f| f.same(&outer)) { - self.g.font = inner; - self.kshow_font = Some(outer); - } + self.kshow_font = outer; + self.g.font = inner; r?; Ok(self.user_point()? - pen) } @@ -960,6 +956,15 @@ struct NamedCid { ems: bool, } +/// A `cshow` procedure running over type in a CIDFont only named ([`Interp::named_cshow`]): the +/// CIDFont it sees as current, the composite font `rootfont` gives while that CIDFont is current, +/// and the type of the code it was given. +pub(super) struct CShowFont { + selected: DictRef, + root: DictRef, + code: NamedCid, +} + /// What drawing a glyph does with it. #[derive(Clone, Copy, PartialEq)] enum Draw { @@ -1092,7 +1097,17 @@ impl Interp<'_> { /// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical /// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't /// read is left out, with a warning, each of its codes moving the point one em. + /// Inside a `cshow` procedure over such type, with the CIDFont current, the string must be the + /// one byte the procedure was given, else a `rangecheck` error, and it stands for the code + /// the procedure was given (PLRM 3rd ed., `cshow`: the glyph of the selected CID). fn named_cid_type(&mut self, s: &[u8]) -> Res> { + if let Some(named) = self.cshow_code(true) { + let given = named.codes.codes.first().map(|&(code, _)| code & 0xff); + if s.len() != 1 || given != s.first().map(|&b| u32::from(b)) { + return ps_err("rangecheck", "a string shown in cshow"); + } + return Ok(Some(named)); + } let Some(cmap) = self.named_cid_parts().map(|(cmap, ..)| cmap) else { return Ok(None) }; // Each code is looked up in the CMap's mappings. self.spend(s.len() as u64)?; @@ -1120,6 +1135,9 @@ impl Interp<'_> { /// The font [`Self::named_cid_type`] sets type in, with no codes yet, when the current font is /// a CID-keyed font over a predefined CMap the file doesn't define. fn named_cid_font(&mut self) -> Res> { + if let Some(named) = self.cshow_code(false) { + return Ok(Some(named)); + } let Some((_, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; // The composite font is copied under the CIDFont's name for every string: charged by // its size, so a huge font dictionary shown in a loop runs out of the budget. @@ -1130,6 +1148,23 @@ impl Interp<'_> { Ok(Some(NamedCid { codes: Decoded::default(), font: DictRef::new(named), vertical, ems: vertical })) } + /// The type of the code a running `cshow` procedure was given ([`Self::named_cshow`]), with + /// that code when `code` and with none otherwise, while the CIDFont it selected is current. + fn cshow_code(&self, code: bool) -> Option { + let c = self.cshow.as_ref()?; + if !self.g.font.as_ref().is_some_and(|f| f.same(&c.selected)) { + return None; + } + let mut codes = Decoded::default(); + if code { + let mut chars = c.code.codes.text.chars(); + for &(value, n) in &c.code.codes.codes { + codes.push(value, chars.by_ref().take(n)); + } + } + Some(NamedCid { codes, font: c.code.font.clone(), vertical: c.code.vertical, ems: c.code.ems }) + } + /// The CMap's name, the composite font, the CIDFont's name and the writing mode of /// [`Self::named_cid_type`]. fn named_cid_parts(&self) -> Option<(String, DictRef, Obj, bool)> { @@ -1191,13 +1226,41 @@ impl Interp<'_> { let before = std::mem::replace(&mut self.kshow_font, saved.clone()); let r = self.show_codes(&named.codes, widths.as_deref(), spacing, named.vertical, &kern, draw); self.kshow_font = before; - // A font the `kshow` procedure set stays current. - if self.g.font.as_ref().is_none_or(|f| f.same(&named.font)) { - self.g.font = saved; - } + self.g.font = saved; r } + /// `cshow`'s procedure `proc` run for each code of type `named` in composite font `root`, with + /// the code's last byte and its width (PLRM 3rd ed., `cshow`). While it runs the CIDFont is the + /// current font and `rootfont` gives `root`; a show operator in the CIDFont shows the code's + /// character, whatever its string (see [`Self::named_cid_type`]). The current font is restored + /// when the procedure completes. + fn named_cshow(&mut self, named: NamedCid, proc: &Obj, root: Option) -> Res { + let Some(composite) = root.clone() else { return Ok(()) }; + let dep = named.font.borrow().get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)); + let selected = match dep { + Some(Obj::Dict(d)) => d, + _ => named.font.clone(), + }; + let mut chars = named.codes.text.chars(); + for &(code, n) in &named.codes.codes { + let mut one = NamedCid { codes: Decoded::default(), font: named.font.clone(), vertical: named.vertical, ems: named.ems }; + one.codes.push(code, chars.by_ref().take(n)); + let v = self.named_cid_width(&one, &one.codes)?; + self.push(Obj::Int(i64::from(code & 0xff)))?; + self.push_num(v.x)?; + self.push_num(v.y)?; + self.cshow = Some(CShowFont { selected: selected.clone(), root: composite.clone(), code: one }); + self.g.font = Some(selected.clone()); + let go = self.body(proc); + self.g.font = root.clone(); + if !go? { + break; + } + } + Ok(()) + } + /// How far codes `codes` of type `named` move the current point (user space) when /// [`Self::show_named_cid`] shows them with no extra spacing. fn named_cid_width(&mut self, named: &NamedCid, codes: &Decoded) -> Res { diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index cfb7af8186..5f1262b8b8 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -755,6 +755,28 @@ fn a_gradient_fill_and_its_stroke_are_one_object() { assert_eq!(n.appearance.stroke_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0))); } +/// An RGB gradient stop's CMYK equivalent is the one of its RGB values as written (to four +/// decimals), so the reopened file writes the same stops. +#[test] +fn rgb_stops_are_written_again_unchanged() { + let mut doc = Document::new(100.0, 100.0); + let rgb = |i: u16| { + let v = |s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + Color::rgb(v(0.137_911_3), v(0.271_828_2), v(0.314_159_3)) + }; + let stops = (0..12u16).map(|i| GradientStop::new(f32::from(i) / 11.0, rgb(i))).collect(); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), paint, Paint::None); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let stops = |bytes: Vec| String::from_utf8(bytes).unwrap().lines().filter(|l| l.ends_with("%_Bs")).map(String::from).collect::>(); + let first = write(&doc).bytes; + let again = write(&crate::import(&first).unwrap().document).bytes; + let (first, again) = (stops(first), stops(again)); + assert_eq!(first.len(), 12, "{first:?}"); + assert_eq!(again, first); +} + /// A gradient from white to #1b2a6b, the colors of #2687's circle, placed at `geom` (None: fitted /// to the object). fn white_to_navy(kind: GradientKind, geom: Option) -> Paint { diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 19d3c59bb1..bbe7b0115b 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -633,7 +633,8 @@ impl<'a> Writer<'a> { // Grey is ink coverage; the format's grey is lightness. Color::Gray { k } => Ink::Gray(unit(1.0 - k)), Color::Cmyk { c, m, y, k } => Ink::Cmyk([c, m, y, k].map(unit)), - Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), [r, g, b].map(unit)), + // The CMYK equivalent of the RGB values as written. + Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(&ai_ops::as_written(*color), intent).map(unit), [r, g, b].map(unit)), Color::Lab { .. } => { let rgb = color.to_rgb().map(unit); Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), rgb) diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index 8fef6d75dd..88da08551e 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -36,6 +36,11 @@ pub(crate) fn push_num(s: &mut String, v: f64) { push_num_places(s, v, 4); } +/// The value [`num`]`(v)` stands for, as a reader parses it back. +pub(crate) fn written(v: f64) -> f64 { + num(v).parse().unwrap_or(0.0) +} + /// Append `v` to `s` with at most `places` decimals (up to 9), no trailing zeros (0 when not /// finite). pub(crate) fn push_num_places(s: &mut String, v: f64, places: u32) { diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4e23ec0842..968cd720a0 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -504,11 +504,23 @@ pub fn load_file(bytes: &[u8]) -> Result { load_file_within(bytes, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES) } +/// [`load_file`] with a compressed file refused when its JSON, or the data after it (images, +/// profiles, the preview), unpacks to more than `max` bytes (at most [`MAX_JSON`] and +/// [`MAX_BLOBS`]), or, when it lacks the marker after its JSON, when all of it does. +pub fn load_file_capped(bytes: &[u8], max: u64) -> Result { + let file = unpack::Unpacked::within_capped(bytes, max.min(MAX_JSON), max.min(MAX_UNPACKED), max.min(MAX_BLOBS))?; + load_unpacked(file, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES) +} + /// [`load_file`] reading at most `max_nodes` objects and buffering at most `max_values` values /// before objects' types, so a test can exercise the budgets without documents that large /// ([`vectorcraft_doc::node::MAX_READ_NODES`], [`vectorcraft_doc::node::MAX_BUFFERED_VALUES`]). fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result { - let file = unpack::Unpacked::new(bytes)?; + load_unpacked(unpack::Unpacked::new(bytes)?, max_nodes, max_values) +} + +/// The document of `file`, within the budgets of [`load_file_within`]. +fn load_unpacked(file: unpack::Unpacked<'_>, max_nodes: usize, max_values: usize) -> Result { // The JSON reader stops at 128 levels (a document's objects use three each), the paths read // at `MAX_READ_ANCHORS` anchors together, the document at `MAX_READ_NODES` objects, and the // fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the @@ -1186,6 +1198,30 @@ mod tests { assert_eq!(back.node(id).unwrap().path_data(), d.node(id).unwrap().path_data()); } + /// A capped read refuses a compressed file whose images unpack past the cap, before reading + /// them, and reads the same file under a cap it fits. + #[test] + fn a_capped_load_caps_the_data_after_the_json() { + use std::io::Write as _; + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + d.images.insert("k".into(), ImageBlob::new("image/png", vec![7; 1 << 16])); + let img = d.alloc_id(); + let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(l), 0, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap(); + // The file gzipped whole, image and all, so the image unpacks from a few bytes. + let raw = save_with(&d, &SaveOptions::default()).unwrap(); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); + gz.write_all(&raw).unwrap(); + let bytes = gz.finish().unwrap(); + assert!(is_compressed(&bytes) && bytes.len() < 1 << 12, "{}", bytes.len()); + let json = unpack::Unpacked::new(&bytes).unwrap().json().len(); + assert!(json < 1 << 15, "{json}"); + assert!(matches!(load_file_capped(&bytes, (1 << 16) - 1), Err(FormatError::TooBig))); + assert_eq!(load_file_capped(&bytes, 3 << 15).unwrap().doc.images["k"].bytes.len(), 1 << 16); + assert_eq!(load_file(&bytes).unwrap().doc.images["k"].bytes.len(), 1 << 16); + } + /// #3587: objects are read within a budget. A compressed file of many small objects stays /// small on disk while they take far more memory once read, so past the budget it is refused /// with a message naming it, before the rest of the document is read. diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index 1573fa0bc7..ec9623118c 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -28,6 +28,13 @@ pub(crate) type Entry = Option>>; pub(crate) struct Unpacked<'a> { json: Json<'a>, tail: Tail<'a>, + /// Most bytes the entries after the JSON may have together ([`MAX_BLOBS`] unless capped). + max_blobs: u64, + /// Most bytes unpacked to find them in a streamed file ([`MAX_WORK`] unless capped). + max_work: u64, + /// Must a streamed file's data after the JSON end within `max_blobs` bytes + /// ([`Self::within_capped`])? + capped: bool, } enum Json<'a> { @@ -68,9 +75,25 @@ impl<'a> Unpacked<'a> { /// a document several times its size, and the cap is what keeps that off the address space /// (#3587). fn within(bytes: &'a [u8], max_json: u64) -> Result { + let (json, tail) = Self::open(bytes, max_json, MAX_UNPACKED)?; + Ok(Self { json, tail, max_blobs: MAX_BLOBS, max_work: max_json.saturating_add(MAX_BLOBS).min(MAX_WORK), capped: false }) + } + + /// [`Self::within`], with a compressed file that lacks the marker after its JSON (unpacked + /// whole) capped at `max_whole` bytes instead of [`MAX_UNPACKED`], the entries after the JSON + /// at `max_blobs` bytes together instead of [`MAX_BLOBS`], and the bytes unpacked to find them + /// at `max_json` and `max_blobs` together instead of [`MAX_WORK`]. A streamed file whose data + /// after the JSON unpacks to more than `max_blobs` bytes is refused when its entries are read. + pub(crate) fn within_capped(bytes: &'a [u8], max_json: u64, max_whole: u64, max_blobs: u64) -> Result { + let (json, tail) = Self::open(bytes, max_json, max_whole)?; + Ok(Self { json, tail, max_blobs, max_work: max_json.saturating_add(max_blobs).min(MAX_WORK), capped: true }) + } + + /// The JSON of `bytes` and where the data after it is, within `max_json` and `max_whole`. + fn open(bytes: &'a [u8], max_json: u64, max_whole: u64) -> Result<(Json<'a>, Tail<'a>), FormatError> { if !is_compressed(bytes) { let (json, tail) = split_tail(bytes); - return Ok(Self { json: Json::Borrowed(json), tail: Tail::Slice(tail) }); + return Ok((Json::Borrowed(json), Tail::Slice(tail))); } let mut dec = GzDecoder::new(bytes); let mut buf = Vec::new(); @@ -93,7 +116,7 @@ impl<'a> Unpacked<'a> { if buf.len() as u64 > max_json { return Err(FormatError::TooBig); } - return Ok(Self { json: Json::Owned(buf), tail: Tail::None }); + return Ok((Json::Owned(buf), Tail::None)); } // The marker where the writer puts it, right after the JSON's last byte: what is unpacked // so far reaches past it. @@ -104,17 +127,17 @@ impl<'a> Unpacked<'a> { let read = buf.split_off(end + BLOB_MAGIC.len()); buf.truncate(end); let at = (end + BLOB_MAGIC.len()) as u64; - return Ok(Self { json: Json::Owned(buf), tail: Tail::Stream { file: bytes, at, read, rest: Box::new(dec) } }); + return Ok((Json::Owned(buf), Tail::Stream { file: bytes, at, read, rest: Box::new(dec) })); } // Anything else (hand-made or damaged files) is unpacked whole and split as an // uncompressed file is. - dec.take(MAX_UNPACKED + 1 - (buf.len() as u64).min(MAX_UNPACKED)).read_to_end(&mut buf).map_err(damaged)?; - if buf.len() as u64 > MAX_UNPACKED { + dec.take(max_whole + 1 - (buf.len() as u64).min(max_whole)).read_to_end(&mut buf).map_err(damaged)?; + if buf.len() as u64 > max_whole { return Err(FormatError::TooBig); } let (json, tail) = split_tail(&buf); let (json, tail) = (json.len(), buf.len() - tail.len()); - Ok(Self { json: Json::Whole { buf, json }, tail: Tail::Whole(tail) }) + Ok((Json::Whole { buf, json }, Tail::Whole(tail))) } /// The JSON text. @@ -133,13 +156,16 @@ impl<'a> Unpacked<'a> { /// of an uncompressed file larger together than both [`MAX_BLOBS`] and the data, or the /// distinct entries found larger together than the data they are in (overlapping entries /// would copy the same bytes once each, #3574). Entries are read in the order of their - /// offsets, each straight into its own buffer. + /// offsets, each straight into its own buffer. A capped file ([`Self::within_capped`]) takes + /// its own totals in place of [`MAX_BLOBS`] and [`MAX_WORK`]. pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result, FormatError> { - self.entries_within(wanted, MAX_WORK) + let work = self.max_work; + self.entries_within(wanted, work) } /// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them. fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result, FormatError> { + let (max_blobs, capped) = (self.max_blobs, self.capped); let mut out: Vec = vec![None; wanted.len()]; // Data in memory is read through the crate's `Entries`: entries naming the same bytes // share them, and the bytes copied stay within the data. @@ -155,11 +181,11 @@ impl<'a> Unpacked<'a> { }; match self.tail { Tail::Slice(tail) => { - within(wanted, u64::MAX, MAX_BLOBS.max(tail.len() as u64))?; + within(wanted, u64::MAX, max_blobs.max(tail.len() as u64))?; in_memory(tail, &mut out)?; } Tail::Whole(at) => { - within(wanted, MAX_BLOB, MAX_BLOBS)?; + within(wanted, MAX_BLOB, max_blobs)?; let buf = match &self.json { Json::Whole { buf, .. } => buf.as_slice(), _ => &[], @@ -168,7 +194,7 @@ impl<'a> Unpacked<'a> { } Tail::None => in_memory(&[], &mut out)?, Tail::Stream { file, at, read, rest } => { - within(wanted, MAX_BLOB, MAX_BLOBS)?; + within(wanted, MAX_BLOB, max_blobs)?; drop(self.json); // The entries read so far, by `[offset, length]`, and the bytes copied for them. let mut done: HashMap<[u64; 2], Entry> = HashMap::new(); @@ -193,9 +219,10 @@ impl<'a> Unpacked<'a> { } done.insert(w, slot.clone()); } - // To the end, so the stream's checksum is verified as before. - s.left = MAX_BLOBS; - s.skip(MAX_BLOBS)?; + // To the end, so the stream's checksum is verified as before. A capped file's data + // must end within its cap from its start: one byte more is over the budget. + s.left = if capped { max_blobs.checked_sub(s.pos).ok_or(FormatError::TooBig)? } else { max_blobs }; + s.skip(if capped { s.left.saturating_add(1) } else { max_blobs })?; } } Ok(out) @@ -317,6 +344,16 @@ mod tests { // An offset past the data is still just missing, and the default budget reads all of it. assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]); assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(Arc::new(tail.clone()))]); + // A capped file finds its entries within its own caps: a small entry far into the data, + // entries larger than the cap together, or data that goes on past the cap after the + // entries, are refused. + let capped = |cap: u64| Unpacked::within_capped(&file, 64, MAX_UNPACKED, cap).unwrap(); + assert_eq!(capped(4096).entries(&[[0, 16]]).unwrap(), vec![Some(Arc::new(tail[..16].to_vec()))]); + assert!(matches!(capped(4095).entries(&[[0, 16]]), Err(FormatError::TooBig))); + assert!(matches!(capped(4095).entries(&[[4095, 1]]), Err(FormatError::TooBig))); + assert!(matches!(capped(16).entries(&[[4000, 1]]), Err(FormatError::TooBig))); + assert!(matches!(capped(16).entries(&[[0, 17]]), Err(FormatError::TooBig))); + assert_eq!(Unpacked::new(&file).unwrap().entries(&[[4000, 1]]).unwrap(), vec![Some(Arc::new(vec![tail[4000]]))]); } /// #3587: a compressed file's JSON is capped wherever the cap runs, so a small gzip that diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 054cfec267..2c4cc23f6e 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -594,7 +594,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel", "Show Panel", "", - "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that can be seen, and shows it again where it was; with the dock hidden by Tab or Shift+Tab, the dock shows again with the panel in front, leaving the toolbar and Control bar as they are; fails and changes nothing in Presentation Mode, which hides every panel", ), ( "window.panel.layout", @@ -652,6 +652,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", ), + ( + "learn.locateTool", + "Show Tool Location", + "", + "{tool: a tool id, as tool.select takes} mark where that tool is for 3 seconds without choosing it, as the link on a tool's name in a Learn tutorial step does: its toolbar button, a row of its collapsed group's flyout (which opens), its group's torn-off strip (which comes to the front), or, when no shown toolbar holds it, its row in the All Tools drawer (which opens); a Tools panel hidden with Window › Tools shows again; Escape or the next locate removes the mark; a missing, non-string or unknown id is an error and changes nothing; returns {tool, location: toolbar|flyout|strip|allTools|unavailable, toolbar: the name of the toolbar made with New Toolbar that holds it, or null}, with reason when Presentation Mode or Tab hides the toolbars", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", @@ -1371,25 +1377,25 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.action.rename", "Rename Action", "", - "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, or an empty name, is an error and renames nothing → {name}", + "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, an empty name, or a name that would give the set's action set file more than 8 MiB (counting the steps the recording in progress adds to it; a name that leaves the file as large or makes it smaller is never refused) is an error and renames nothing → {name}", ), ( "ui.action.stepsIncluded", "Include Action Steps", "", - "{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing → {set, action?, step?, included}", + "{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing. An exclusion that would give the set's action set file more than 8 MiB, counting the steps the recording in progress adds to the set, is an error and changes nothing; a change that leaves the file as large or makes it smaller is never refused → {set, action?, step?, included}", ), ( "ui.action.new", "New Action", "", - "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, or a key or color that is not one of these, it is an error and nothing is added → {set, action}", + "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}", ), ( "ui.action.options", "Action Options", "", - "{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \"\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, or a key or color that is not one of these is an error and changes nothing → {name, key, color}", + "{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \"\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, a key or color that is not one of these, or a change that would give the set's action set file more than 8 MiB (counting the steps the recording in progress adds to it; a change that leaves the file as large or makes it smaller is never refused) is an error and changes nothing → {name, key, color}", ), ( "ui.action.play", @@ -1407,13 +1413,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.action.record", "Begin Recording", "", - "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set or an existing recording is an error and changes nothing → {set, name}", + "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}", ), ( "ui.action.stop", "Stop Playing/Recording", "", - "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. With nothing recording or playing it changes nothing → {stopped}", + "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", ), ( "ui.action.insertStop", @@ -1455,7 +1461,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.actionSet.load", "Load Actions…", "", - "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", + "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", ), ( "ui.actionSet.delete", @@ -1467,7 +1473,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.actionSet.duplicate", "Duplicate Action or Action Set", "", - "{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \" copy\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action is an error → {set, action?}", + "{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \" copy\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action, or a copy that would give its set more than 1,000 actions or an action set file larger than 8 MiB, is an error and copies nothing → {set, action?}", ), ("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"), ( diff --git a/crates/pathops/src/addons/pathtools/crossings.rs b/crates/pathops/src/addons/pathtools/crossings.rs index e1d96ed0fd..d216a2f4b3 100644 --- a/crates/pathops/src/addons/pathtools/crossings.rs +++ b/crates/pathops/src/addons/pathtools/crossings.rs @@ -182,7 +182,19 @@ pub fn insert_at(sp: &SubPath, places: &[(usize, f64)]) -> (SubPath, BTreeSet(); + // How many anchors were added before each anchor, counted in one pass for all of them. + let mut before = vec![0usize; n + 1]; + for (&seg, ts) in &inner { + if let Some(b) = before.get_mut(seg + 1) { + *b += ts.len(); + } + } + let mut sum = 0; + for b in &mut before { + sum += *b; + *b = sum; + } + let added_before = |j: usize| before.get(j).copied().unwrap_or(0); let mut marks: BTreeSet = at_anchor.into_iter().map(|j| j + added_before(j)).collect(); for (&seg, ts) in &inner { let base = seg + added_before(seg); diff --git a/crates/pdf/src/tests/stroke.rs b/crates/pdf/src/tests/stroke.rs index 91436a093c..d57cbc42d0 100644 --- a/crates/pdf/src/tests/stroke.rs +++ b/crates/pdf/src/tests/stroke.rs @@ -52,7 +52,7 @@ pub(crate) fn painted_boxes(d: &Document) -> (Vec, Vec) { fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for kind in Arrowhead::ALL { - let (d, n) = line_doc(arrow_stroke(kind, align, 1.0)); + let (d, n) = line_doc(arrow_stroke(kind.clone(), align, 1.0)); let bp = n.path_data().unwrap().to_bezpath(); let st = n.appearance.stroke().unwrap(); let pieces = stroke_pieces(&bp, st); diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 98cf893dee..26ba5b668b 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -1244,8 +1244,11 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, match o.id { Some(id) => { let mut n = originals.remove(&id).ok_or_else(|| { - let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" }; - out_err(&at, format!("{} {why}", id.0)) + if wanted.contains(&id) { + out_err(&at, format!("{} is listed twice", id.0)) + } else { + out_err(&at, format!("{} is not the id of an input object", id.0)) + } })?; // Type, images and symbol instances are only read: writing one would make it a path. if input_kind(&n).is_some() && !o.kept { diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index c03a6cc0f3..96bddc17f1 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -307,6 +307,36 @@ fn drop_shadow_darkens_outside_the_shape() { assert_eq!(lum(img.pixel(15, 15)), WHITE); } +/// A Transform effect listed after a Drop Shadow transforms the shadow with the art: halving a +/// 40 pt square about its center halves the shadow's 10 pt offset, with Scale Strokes & Effects +/// off or on, and for a group as for a path. +#[test] +fn a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art() { + let r = Rect::new(20.0, 20.0, 60.0, 60.0); + let shadow = ("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"})); + for strokes in [false, true] { + let transform = ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "strokes": strokes})); + let path = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![shadow.clone(), transform.clone()]); + let mut group = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![]); + let l = group.layers[0].id; + let square = group.layers[0].children().and_then(|c| c.first()).cloned().unwrap(); + group.remove(square.id).unwrap(); + let mut g = Node::group(group.alloc_id(), vec![square]); + g.appearance.effects = [shadow.clone(), transform].into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); + group.insert(Some(l), 0, g).unwrap(); + for (what, d) in [("path", path), ("group", group)] { + let img = render(&d); + // The square now covers 30..50 and its shadow 35..55. + assert_eq!(img.pixel(40, 40), [255, 0, 0, 255], "{what}, Scale Strokes & Effects {strokes}"); + assert!(lum(img.pixel(53, 37)) < 100, "{what}, {strokes}: the shadow reaches up to 35: {:?}", img.pixel(53, 37)); + assert_eq!(lum(img.pixel(57, 57)), WHITE, "{what}, {strokes}: the shadow ends at 55"); + // The area redrawn for it takes in the 5 pt shadow around the 30..50 square. + let b = super::cull_bounds(&d.symbol_boxes(), &d.layers[0].children().unwrap()[0]).unwrap(); + assert!((b.x0 - 25.0).abs() < 1e-9 && (b.x1 - 55.0).abs() < 1e-9, "{what}, {strokes}: {b:?}"); + } + } +} + /// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and /// right; `shade` "color" or "darkness". fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document { diff --git a/crates/render/tests/it/golden.rs b/crates/render/tests/it/golden.rs index 4820cf8b60..d6962e0e67 100644 --- a/crates/render/tests/it/golden.rs +++ b/crates/render/tests/it/golden.rs @@ -300,7 +300,7 @@ fn arrowheads_add_ink_at_the_ends() { // Every arrowhead kind renders. for a in Arrowhead::ALL { let mut b = DocBuilder::new(100.0, 100.0); - b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a)), |_| {}); + b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a.clone())), |_| {}); assert!(render(&b.build()).ink() > plain.ink(), "{a:?}"); } } diff --git a/crates/render/tests/it/stroke_geometry.rs b/crates/render/tests/it/stroke_geometry.rs index a8813da23a..2580fc79c8 100644 --- a/crates/render/tests/it/stroke_geometry.rs +++ b/crates/render/tests/it/stroke_geometry.rs @@ -114,7 +114,7 @@ fn a_hollow_circle_head_is_empty_inside() { fn heads_are_drawn_where_the_shared_geometry_puts_them_and_never_past_the_tip() { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for kind in Arrowhead::ALL { - let d = arrow_doc(kind, align, 1.0, |s| s.cap = LineCap::Round); + let d = arrow_doc(kind.clone(), align, 1.0, |s| s.cap = LineCap::Round); let h = head_of(&d); if align == ArrowAlign::Tip { assert_eq!(h.tip, Point::new(80.0, 50.0)); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 8eb18626f3..5fed1fb121 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -26,6 +26,7 @@ mod nodes; mod output; mod paint; mod text; +mod variables; use nodes::SymbolDef; pub(crate) use nodes::{MASK_FLAGS, Reuse}; @@ -117,8 +118,10 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: layer_nest: 0, non_latin1_names: false, foreign_ns: BTreeMap::new(), + bound: HashMap::new(), }; w.assign_name_ids(); + w.assign_bound_ids(); for layer in &doc.layers { w.count_colorized_images(layer); } @@ -135,6 +138,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: w.line(""); } + // Include Variable Data, once the body has written the bound objects' ids. + let variables = w.variable_sets(); // XML names cannot use character references. Preserve names that Latin-1 cannot encode. let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names { w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); @@ -207,6 +212,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.indent(1)); out.push_str(&format!(""})), + ( + "command.batch", + json!({"label": "B", "commands": [{"command": "object.move", "params": {"dx": 1, "n": {"a": [1, 2.5, {"b": null}]}}}]}), + ), + (STOP_STEP, json!({"message": "Paint it", "allowContinue": false})), + ("object.group", json!({})), + ("object.group", json!([])), + ("object.group", Value::Null), + ]; + for (command, params) in steps { + for first in [true, false] { + let before = if first { vec![] } else { vec![("object.group".to_string(), json!({}))] }; + let mut set = ActionSet::new("Set", vec![Action::new("A", before), Action::new("B", vec![("object.group".into(), json!({}))])]); + let len = files::write_set(&set).len(); + set.actions[0].steps.push((command.to_string(), params.clone())); + let added = files::write_set(&set).len() - len; + let counted = files::step_bytes(command, ¶ms); + let first_bytes = if first { files::FIRST_STEP_BYTES } else { 0 }; + assert_eq!(counted + first_bytes, added, "{command} {params}, first: {first}"); + } + } + let mut set = ActionSet::new("Set", vec![Action::new("A", vec![("object.group".into(), json!({"n": "日本"})); 3])]); + let len = files::write_set(&set).len(); + set.actions[0].excluded_steps = BTreeSet::from([0, 2]); + assert_eq!(files::write_set(&set).len(), len + 2 * files::EXCLUDED_BYTES); + } + + /// The params of `object.move` by 1 as the journal keeps them, which a recording records. + fn recorded_move(app: &mut VectorcraftApp) -> Value { + app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap(); + let moved = app.session.journal.last().unwrap().1.clone(); + app.run("edit.undo", json!({})).unwrap(); + moved + } + + /// An ordinary command recorded as the 10,001st step of an action does not run: the document, + /// the journal and the undo history stay as they were, the recording goes on, and Stop + /// Playing/Recording leaves a set that Load Actions… reads. This holds for Begin Recording's new + /// action and for New Action's recording into an action with steps of its own. + #[test] + fn recorded_commands_keep_an_action_at_10_000_steps() { + for new_action in [false, true] { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + let filler = ("object.group".to_string(), json!({})); + if new_action { + app.run(NEW_ACTION, json!({"set": 0, "name": "Long"})).unwrap(); + app.ui.action_sets[0].actions[1].steps = vec![filler; files::MAX_ACTION_STEPS - 1]; + } else { + app.run(START_RECORDING, json!({})).unwrap(); + for _ in 1..files::MAX_ACTION_STEPS { + app.session.journal.push(filler.clone()); + } + } + app.run("object.move", moved.clone()).unwrap(); + assert_eq!(x(&app), 1.0, "the 10,000th step runs"); + let (journal, undo) = (app.session.journal.len(), undo_steps(&app)); + let steps = "the action already has 10,000 steps"; + assert_eq!(app.run("object.move", moved.clone()).unwrap_err(), steps, "new action: {new_action}"); + assert_eq!((x(&app), app.session.journal.len(), undo_steps(&app)), (1.0, journal, undo), "nothing ran"); + assert_eq!(app.ui.status, steps); + assert!(app.ui.recording.is_some(), "the recording goes on"); + assert!(app.run("effect.list", json!({})).is_ok(), "a command that is not recorded still runs"); + let find = |select: bool| json!({"minWidth": 0, "select": select}); + assert_eq!(app.run("document.find", find(true)).unwrap_err(), steps, "Find Objects that selects is recorded"); + assert!(app.run("document.find", find(false)).is_ok()); + let batch = json!({"commands": [{"command": "object.move", "params": moved.clone()}]}); + assert_eq!(app.run("command.batch", batch).unwrap_err(), steps, "a batch is recorded"); + assert_eq!(x(&app), 1.0); + assert_eq!(app.session.journal.len(), journal); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[1].steps.len(), files::MAX_ACTION_STEPS); + assert_eq!(app.ui.action_sets[0].actions[1].steps.last(), Some(&("object.move".to_string(), moved))); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(files::read_set(&saved, "Test").unwrap().actions[1].steps.len(), files::MAX_ACTION_STEPS, "and loads again"); + } + } + + /// An ordinary command whose step would give the set of the action it is recorded into a file + /// larger than 8 MiB does not run, and one that makes the file exactly 8 MiB does. + #[test] + fn recorded_commands_keep_an_action_set_file_within_8_mib() { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + let added = files::step_bytes("object.move", &moved); + app.ui.action_sets[0].actions[0].steps = vec![pad(room - added)]; + let into = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + app.run("object.move", moved.clone()).unwrap(); + assert_eq!(x(&app), 1.0, "the step that fills the file runs"); + let (journal, undo) = (app.session.journal.len(), undo_steps(&app)); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run("object.move", moved).unwrap_err(), size); + assert_eq!((x(&app), app.session.journal.len(), undo_steps(&app)), (1.0, journal, undo), "nothing ran"); + assert!(app.ui.recording.is_some(), "the recording goes on"); + app.ui.status.clear(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.status, "", "Stop keeps every recorded step"); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(saved.len(), files::MAX_SET_FILE); + assert_eq!(files::read_set(&saved, "Test").unwrap().actions[0].steps.len(), 2, "and loads again"); + } + + /// A command whose step fits by its params but not with the values it adds to its step as it + /// runs (Scale's Scale Strokes & Effects and Scale Corners) keeps its edit, the status bar names + /// the limit, the next recorded command does not run, and Stop Playing/Recording leaves the step + /// out. + #[test] + fn a_recorded_command_whose_noted_values_do_not_fit_is_left_out() { + let (mut app, x) = app_with_three_moves(); + let scaled = json!({"sx": 200}); + app.run("object.scale", scaled.clone()).unwrap(); + let noted = app.session.journal.last().unwrap().1.clone(); + app.run("edit.undo", json!({})).unwrap(); + assert!(files::step_bytes("object.scale", ¬ed) > files::step_bytes("object.scale", &scaled), "Scale notes values: {noted}"); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + // Room for the step as given, not for the step as recorded. + app.ui.action_sets[0].actions[0].steps = vec![pad(room - files::step_bytes("object.scale", &scaled))]; + let into = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + let size = "the action set would be larger than 8 MiB"; + app.run("object.scale", scaled).unwrap(); + assert_eq!(app.ui.status, size); + let scaled_x = x(&app); + assert_ne!(scaled_x, 0.0, "the scale stayed"); + assert_eq!(app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap_err(), size, "no later command runs"); + assert_eq!(x(&app), scaled_x); + assert!(app.ui.recording.is_some(), "the recording goes on"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 1, "the scale's step is left out"); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// When the action New Action made for the recording in progress is deleted, Stop + /// Playing/Recording keeps none of its steps, so no limit stops a recorded command, even with + /// the set's file at 8 MiB. + #[test] + fn a_deleted_recording_action_does_not_limit_recorded_commands() { + let (mut app, x) = app_with_three_moves(); + let gone = Action::new("Gone", vec![]); + let id = gone.id; + app.ui.action_sets[0].actions.push(gone); + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(id); + app.ui.action_sets[0].actions.retain(|a| a.id != id); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.ui.action_sets[0].actions[0].steps = vec![pad(room)]; + let before = x(&app); + app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap(); + assert_ne!(x(&app), before, "the move ran"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.len(), 1, "no action is added"); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// A stop that playback reaches while a full action records is not recorded: the status bar shows + /// the reason, and the playback still waits at the stop. + #[test] + fn a_stop_reached_while_recording_a_full_action_is_left_out() { + let (mut app, _) = app_with_a_stop(false); + app.run(START_RECORDING, json!({})).unwrap(); + for _ in 1..files::MAX_ACTION_STEPS { + app.session.journal.push(("object.group".to_string(), json!({}))); + } + play(&mut app, 0, 0).unwrap(); + assert!(waiting_at_stop(&app)); + assert_eq!(app.ui.status, "the action already has 10,000 steps"); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!(recorded.steps.len(), files::MAX_ACTION_STEPS); + assert_eq!(recorded.steps.last().map(|(c, _)| c.as_str()), Some("command.batch")); + } + + /// Steps that reach the journal without a command's check are left out by Stop Playing/Recording + /// past either limit, and the status bar names the limit. + #[test] + fn stop_leaves_out_recorded_steps_past_the_limits() { + let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({})).unwrap(); + for _ in 0..files::MAX_ACTION_STEPS + 5 { + app.session.journal.push(("object.group".to_string(), json!({}))); + } + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.status, "the action already has 10,000 steps"); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), files::MAX_ACTION_STEPS); + + let (mut app, _) = app_with_three_moves(); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.run(START_RECORDING, json!({})).unwrap(); + app.session.journal.push(pad(files::MAX_SET_FILE / 2)); + app.session.journal.push(pad(files::MAX_SET_FILE / 2)); + app.session.journal.push(pad(0)); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.status, "the action set would be larger than 8 MiB"); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), 1); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// An exclusion that would give a set a file larger than 8 MiB is refused for a set, an action, + /// a step and the other steps of an action, and leaves every exclusion as it was. One that + /// fills the file exactly, one that changes nothing and an inclusion still work, also in a set + /// that is already larger than 8 MiB. + #[test] + fn excluding_steps_keeps_an_action_set_file_within_8_mib() { + let (mut app, _) = app_with_three_moves(); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions.push(Action::new("Pad", vec![pad(0)])); + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + // Room for two more excluded steps. + app.ui.action_sets[0].actions[1].steps = vec![pad(room - 2 * files::EXCLUDED_BYTES)]; + let size = "the action set would be larger than 8 MiB"; + let include = |app: &mut VectorcraftApp, p: Value| app.run(INCLUDE_STEPS, p); + let excluded = |app: &VectorcraftApp| app.ui.action_sets[0].actions.iter().map(|a| a.excluded_steps.clone()).collect::>(); + let none = vec![BTreeSet::new(), BTreeSet::new()]; + assert_eq!(include(&mut app, json!({"set": 0, "included": false})).unwrap_err(), size); + assert_eq!(include(&mut app, json!({"set": 0, "action": 0, "included": false})).unwrap_err(), size); + assert_eq!(excluded(&app), none, "nothing changed"); + // Two steps fill the file to 8 MiB. + include(&mut app, json!({"set": 0, "action": 0, "step": 0, "included": false, "others": true})).unwrap(); + assert_eq!(excluded(&app), [BTreeSet::from([1, 2]), BTreeSet::new()]); + assert_eq!(files::write_set(&app.ui.action_sets[0]).len(), files::MAX_SET_FILE); + assert_eq!(include(&mut app, json!({"set": 0, "action": 0, "step": 0, "included": false})).unwrap_err(), size); + assert_eq!(include(&mut app, json!({"set": 0, "action": 1, "step": 0, "included": false})).unwrap_err(), size); + assert!(include(&mut app, json!({"set": 0, "action": 0, "step": 1, "included": false})).is_ok(), "no change"); + include(&mut app, json!({"set": 0, "action": 0, "step": 1, "included": true})).unwrap(); + include(&mut app, json!({"set": 0, "action": 0, "step": 0, "included": false})).unwrap(); + assert_eq!(excluded(&app), [BTreeSet::from([0, 2]), BTreeSet::new()]); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + + // A set already larger than 8 MiB can still include its steps. + app.ui.action_sets[0].actions[1].steps[0] = pad(room); + assert_eq!(include(&mut app, json!({"set": 0, "action": 0, "step": 1, "included": false})).unwrap_err(), size); + include(&mut app, json!({"set": 0, "included": true})).unwrap(); + assert_eq!(excluded(&app), none); + + // The steps a recording adds to the set count too. + let (mut app, _) = app_with_three_moves(); + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.run(START_RECORDING, json!({})).unwrap(); + app.session.journal.push(pad(room - files::step_bytes("object.move", &pad(0).1))); + assert_eq!(include(&mut app, json!({"set": 0, "included": false})).unwrap_err(), size); + assert!(app.ui.action_sets[0].actions[0].excluded_steps.is_empty()); + } + + /// Adds an action to set `set` whose step makes the set's file `short` bytes short of 8 MiB. + fn pad_set_to(app: &mut VectorcraftApp, set: usize, short: usize) { + let pad = |n: usize| ("object.move".to_string(), json!({"pad": "x".repeat(n)})); + let s = &mut app.ui.action_sets[set]; + s.actions.push(Action::new("Pad", vec![pad(0)])); + let room = files::MAX_SET_FILE - files::write_set(s).len(); + s.actions.last_mut().unwrap().steps[0] = pad(room - short); + assert_eq!(files::write_set(s).len(), files::MAX_SET_FILE - short); + } + + /// `n` actions with no steps, named A0, A1 … + fn empty_actions(n: usize) -> Vec { + (0..n).map(|i| Action::new(format!("A{i}"), vec![])).collect() + } + + /// A recorded batch that edits another document, and whose values make its step too large only + /// as it runs, keeps its edits in both documents: the status bar names the limit, the next + /// recorded command does not run, and Stop Playing/Recording leaves the batch's step out. + #[test] + fn a_recorded_batch_that_edits_another_document_is_left_out_when_it_grows() { + // Three moves' document, a move as recorded there, and a second document that is active. + let two_documents = || { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + (app, x, moved) + }; + let batch = json!({"commands": [ + {"command": "document.activate", "params": {"index": 0}}, + {"command": "object.scale", "params": {"sx": 200}}, + ]}); + let (mut app, _, _) = two_documents(); + app.run("command.batch", batch.clone()).unwrap(); + let noted = app.session.journal.last().unwrap().1.clone(); + // The batch as the app runs it (with the active artboard) before the scale notes its values. + let mut sent = noted.clone(); + sent["commands"][1]["params"] = json!({"sx": 200}); + let given = files::step_bytes("command.batch", &sent); + assert!(files::step_bytes("command.batch", ¬ed) > given, "the scale notes values: {noted}"); + + let (mut app, x, moved) = two_documents(); + pad_set_to(&mut app, 0, given); + let into = app.ui.action_sets[0].actions.last().unwrap().id; + app.run(START_RECORDING, json!({"set": 0})).unwrap(); + app.ui.recording_into = Some(into); + app.run("command.batch", batch).unwrap(); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.ui.status, size); + let scaled = x(&app); + assert_ne!(scaled, 0.0, "the scale stayed"); + assert_eq!(app.run("object.move", moved).unwrap_err(), size, "no later command runs"); + assert_eq!(x(&app), scaled); + let pad = app.ui.action_sets[0].actions.last().unwrap().steps.len(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), pad, "the batch's step is left out"); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// Set Options… renaming the set that an action records into has the next recorded command + /// measure the set's file again: a step that fit before the longer name does not run after it. + #[test] + fn a_set_renamed_while_it_records_is_measured_again() { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + let added = files::step_bytes("object.move", &moved); + pad_set_to(&mut app, 0, 2 * added + 3); + let into = app.ui.action_sets[0].actions.last().unwrap().id; + app.run(START_RECORDING, json!({"set": 0})).unwrap(); + app.ui.recording_into = Some(into); + app.run("object.move", moved.clone()).unwrap(); + crate::dialogs::action_set_options::open(&mut app, 0); + let longer = format!("{} 1234", app.ui.action_sets[0].name); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!(longer)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.action_sets[0].name, longer); + assert_eq!(app.run("object.move", moved).unwrap_err(), "the action set would be larger than 8 MiB"); + assert_eq!(x(&app), 1.0, "only the first move ran"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// Steps recorded into an action are included in playback, also when the action holds an + /// exclusion past its last step, as saved preferences can; the set's file stays within 8 MiB. + #[test] + fn steps_recorded_after_an_exclusion_past_the_last_step_are_included() { + let (mut app, _) = app_with_three_moves(); + let moved = recorded_move(&mut app); + let added = files::step_bytes("object.move", &moved); + pad_set_to(&mut app, 0, added); + let pad = app.ui.action_sets[0].actions.last_mut().unwrap(); + pad.excluded_steps.insert(1); + let into = pad.id; + app.run(START_RECORDING, json!({"set": 0})).unwrap(); + app.ui.recording_into = Some(into); + app.run("object.move", moved).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + let pad = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!((pad.steps.len(), pad.excluded_steps.len()), (2, 0), "the recorded step is included"); + assert_eq!(files::write_set(&app.ui.action_sets[0]).len(), files::MAX_SET_FILE); + } + + /// Duplicate copies no action or set when the copy would give a file larger than 8 MiB or a set + /// of more than 1,000 actions, and still copies one that fits. + #[test] + fn duplicate_keeps_action_set_files_within_their_limits() { + let mut app = app_with_sets(); + pad_set_to(&mut app, 1, 0); + let size = "the action set would be larger than 8 MiB"; + let names = set_names(&app); + assert_eq!(app.run(DUPLICATE, json!({"set": 1, "action": 0})).unwrap_err(), size); + assert_eq!(app.run(DUPLICATE, json!({"set": 1})).unwrap_err(), size, "the copy's name is longer"); + assert_eq!((set_names(&app), app.ui.action_sets[1].actions.len()), (names, 3), "nothing was copied"); + app.ui.action_sets[2].actions = empty_actions(1000); + let many = "the action set file holds more than 1,000 actions"; + assert_eq!(app.run(DUPLICATE, json!({"set": 2, "action": 0})).unwrap_err(), many); + assert_eq!(app.ui.action_sets[2].actions.len(), 1000); + app.ui.action_sets[2].actions.pop(); + app.run(DUPLICATE, json!({"set": 2, "action": 0})).unwrap(); + app.run(DUPLICATE, json!({"set": 2})).unwrap(); + assert_eq!((app.ui.action_sets[2].actions.len(), app.ui.action_sets[3].actions.len()), (1000, 1000)); + } + + /// New Action and Begin Recording add no action and start no recording when the action would + /// give its set more than 1,000 actions or a file larger than 8 MiB, and New Action adds one that + /// fits. + #[test] + fn new_action_keeps_an_action_set_file_within_its_limits() { + let mut app = app_with_sets(); + app.ui.action_sets[2].actions = empty_actions(1000); + let many = "the action set file holds more than 1,000 actions"; + assert_eq!(app.run(NEW_ACTION, json!({"set": 2, "name": "More"})).unwrap_err(), many); + assert_eq!(app.run(START_RECORDING, json!({"set": 2})).unwrap_err(), many, "Begin Recording too"); + pad_set_to(&mut app, 1, 200); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run(NEW_ACTION, json!({"set": 1, "name": "x".repeat(300)})).unwrap_err(), size); + assert!(app.ui.recording.is_none(), "no recording started"); + assert_eq!((app.ui.action_sets[1].actions.len(), app.ui.action_sets[2].actions.len()), (3, 1000)); + app.run(NEW_ACTION, json!({"set": 1, "name": "More"})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[1].actions.len(), 4); + assert!(files::write_set(&app.ui.action_sets[1]).len() <= files::MAX_SET_FILE); + } + + /// Renaming an action, Action Options and Set Options change nothing when the change would give + /// the set a file larger than 8 MiB, with the steps a recording adds to the set; a change that + /// fills the file exactly, or that makes a set already past 8 MiB smaller, is made. New Set adds + /// no set whose file would be larger than 8 MiB. + #[test] + fn renaming_keeps_an_action_set_file_within_8_mib() { + let mut app = app_with_sets(); + pad_set_to(&mut app, 1, 2); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudge Far"})).unwrap_err(), size); + assert_eq!(app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "color": "red"})).unwrap_err(), size); + assert_eq!(app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "key": 2})).unwrap_err(), size); + let nudge = &app.ui.action_sets[1].actions[0]; + assert_eq!((nudge.name.as_str(), nudge.key, nudge.color.as_deref()), ("Nudge", None, None), "nothing changed"); + crate::dialogs::action_set_options::open(&mut app, 1); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Mine and More")); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap_err(), size); + assert!(app.ui.dialog.is_some(), "Set Options stays open"); + assert_eq!(app.ui.action_sets[1].name, "Mine"); + app.ui.dialog = None; + app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudge++"})).unwrap(); + assert_eq!(files::write_set(&app.ui.action_sets[1]).len(), files::MAX_SET_FILE, "the file is exactly 8 MiB"); + app.ui.action_sets[1].actions[1].name = "x".repeat(100); + app.run(RENAME_ACTION, json!({"set": 1, "action": 1, "name": "Nudge Again"})).unwrap(); + assert_eq!(app.ui.action_sets[1].actions[1].name, "Nudge Again", "a set past 8 MiB gets smaller"); + + crate::dialogs::action_set_options::open_new(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("x".repeat(files::MAX_SET_FILE))); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap_err(), size); + assert_eq!(app.ui.action_sets.len(), 3, "no set was added"); + app.ui.dialog = None; + + // The steps a recording adds to the set count too. + let mut app = app_with_sets(); + pad_set_to(&mut app, 1, 1000); + app.run(START_RECORDING, json!({"set": 1})).unwrap(); + app.session.journal.push(("object.move".to_string(), json!({"pad": "x".repeat(1000)}))); + assert_eq!(app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudge+"})).unwrap_err(), size); + app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudg"})).unwrap(); + } + + /// Load Actions… adds no set that Save Actions… would write as a file larger than 8 MiB: a file + /// with less white space, a file whose set name gets a number, and another app's action set whose + /// names take more bytes in the file. + #[test] + fn load_actions_refuses_a_set_that_would_save_larger_than_8_mib() { + let mut app = app_with_sets(); + let size = "the action set would be larger than 8 MiB"; + let wide = ActionSet::new("Wide", vec![Action::new("Zeros", vec![("object.move".into(), json!({"p": vec![0; 600_000]}))])]); + let saved = files::write_set(&wide); + let packed = serde_json::to_string(&serde_json::from_str::(&saved).unwrap()).unwrap(); + assert!(packed.len() <= files::MAX_SET_FILE && saved.len() > files::MAX_SET_FILE); + assert_eq!(app.run(LOAD_SET, json!({ "data": packed })).unwrap_err(), size); + + let mut full = app_with_sets(); + full.ui.action_sets[1].actions.clear(); + pad_set_to(&mut full, 1, 0); + let mine = files::write_set(&full.ui.action_sets[1]); + assert_eq!(app.run(LOAD_SET, json!({ "data": mine })).unwrap_err(), size, "Mine 2 is longer"); + let yours = mine.replacen("\"Mine\"", "\"Your\"", 1); + assert_eq!(app.run(LOAD_SET, json!({ "data": yours })).unwrap()["name"], "Your"); + + let name = "\u{1}".repeat(1024); + let steps = [("Group", true); 40]; + let text = aia_text("Names", &vec![(name.as_str(), steps.as_slice()); 1000]); + assert!(text.len() <= files::MAX_SET_FILE); + assert_eq!(app.run(LOAD_SET, json!({ "data": text })).unwrap_err(), size); + assert_eq!(set_names(&app), ["Default Actions", "Mine", "Other", "Your"]); + } + /// A step inserted from the menu saves and loads as the command and params it holds, with its /// place and exclusion. #[test] diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index 2e1385a7d8..fd8b40fbd7 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -31,7 +31,7 @@ pub const ACTIONS_EXT: &str = "vcactions"; pub(super) const MAX_SET_FILE: usize = 8 << 20; /// The most actions [`read_set`] reads from one file. -const MAX_SET_ACTIONS: usize = 1000; +pub(super) const MAX_SET_ACTIONS: usize = 1000; /// The most steps [`read_set`] reads in one action. pub(super) const MAX_ACTION_STEPS: usize = 10_000; @@ -74,6 +74,51 @@ fn is_false(value: &bool) -> bool { !value } +/// A step as [`write_set`] writes it when it is included: [`FileStep`] without `excluded`. +#[derive(Serialize)] +struct IncludedStep<'a> { + command: &'a str, + params: &'a Value, +} + +/// The bytes that excluding one step adds to the text [`write_set`] writes: a comma, a line +/// break, the indent and `"excluded": true`. +pub(super) const EXCLUDED_BYTES: usize = 28; + +/// The bytes more that a step adds to the text [`write_set`] writes as an action's first step than +/// [`step_bytes`] counts: a line break on each side of the step and the indent of the closing +/// bracket, in place of a comma and a line break. +pub(super) const FIRST_STEP_BYTES: usize = 6; + +/// The bytes that the included step `(command, params)` adds to the text [`write_set`] writes when +/// it goes after the last step of an action. As the action's first step it adds +/// [`FIRST_STEP_BYTES`] more. +pub(super) fn step_bytes(command: &str, params: &Value) -> usize { + /// Counts the bytes and the line breaks written to it. + #[derive(Default)] + struct Count { + bytes: usize, + breaks: usize, + } + impl std::io::Write for Count { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.bytes += buf.len(); + self.breaks += buf.iter().filter(|&&b| b == b'\n').count(); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut count = Count::default(); + if serde_json::to_writer_pretty(&mut count, &IncludedStep { command, params }).is_err() { + return MAX_SET_FILE + 1; + } + // In the file every line of the step is indented by 8 more spaces, and a comma and a line break + // come before it. + count.bytes + 8 * (count.breaks + 1) + 2 +} + /// The params of the stops in a step: a stop's own, or those of the stops a `command.batch` step /// holds, which playback runs as its own steps. fn stops_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { @@ -329,6 +374,8 @@ fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result { } let AiaLoad { mut set, skipped, left_out } = aia_set(&read, "Actions"); set.name = free_set_name(&app.ui.action_sets, &set.name); + // The set must save as a file that Load Actions… reads, which can be larger than the file read. + super::edit_keeps_set_loadable(app, None, &set)?; let (name, actions) = (set.name.clone(), set.actions.len()); app.ui.action_sets.push(set); if left_out > 0 { @@ -350,14 +397,17 @@ fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result { /// same name as one in the panel gets the next free number (`Print 2`). Loaded actions keep their /// function keys. The current set and the other sets stay as they are → /// `{set: index, name, actions: n}`. Where the host can read part of a file (the desktop), a file -/// larger than 8 MiB is refused after its first 8 MiB and one byte are read. +/// larger than 8 MiB is refused after its first 8 MiB and one byte are read. A set that Save +/// Actions… would write as a file Load Actions… refuses (larger than 8 MiB, as a file with less +/// white space or a name that gets a number can give) is refused too, and no set is added. /// /// An action set another app saved as text (`.aia`, read with `actionSet.read`) comes in with its /// actions and the steps that stand for a VectorCraft command ([`step_name`], [`step_commands`]), /// with no params; a step that is off in the file is excluded from playback. A set with no name is /// called after the file, else `Actions`. The other steps are left out and listed, and the status /// bar says how many → `{set, name, actions, skipped: [{action, step}] (at most 1,000), leftOut: n}`. -/// Such a file that `actionSet.read` marks `truncated` is refused, and no set is added. +/// Such a file that `actionSet.read` marks `truncated` is refused, and so is one whose set Save +/// Actions… would write larger than 8 MiB; then no set is added. pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { let (text, stem) = match p.get("data") { Some(Value::String(data)) => { @@ -398,6 +448,8 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { }; let mut set = read_set(&text, if stem.is_empty() { "Actions" } else { &stem })?; set.name = free_set_name(&app.ui.action_sets, &set.name); + // A number added to the name can make a file at the limit larger than it. + super::edit_keeps_set_loadable(app, None, &set)?; let (name, actions) = (set.name.clone(), set.actions.len()); app.ui.action_sets.push(set); Ok(json!({ "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions })) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 2f834a272c..adf6cacf3b 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -450,10 +450,10 @@ impl Field { /// Type tool edits, the panel shows, nothing takes the focus and the command fails. → `{field}`. pub(crate) fn highlight(app: &mut VectorcraftApp, field: Field) -> Result { // `window.panel` would hide a floating panel in front of its window, or an open flyout. - if crate::panel_docking::showing(&app.ui, "character") { + if crate::panel_docking::visible(&app.ui, "character") { app.run("window.panel.activate", json!({"panel": "character"}))?; } else { - app.run("window.panel", json!({"panel": "character"}))?; + crate::panel_docking::reveal_panel(app, "character")?; } if text_style(app).is_none() { app.character_focus = None; diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index e49832c0da..cce131c7a3 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -2,10 +2,12 @@ //! hands-on tutorials grouped by skill ([`catalog::SKILLS`]). The For you tab holds the Get started //! section with the Quick skill guide card, which opens the Skills tab. The Skills tab lists each //! skill with an empty progress ring and its count of tutorials; a skill lists its tutorials, and a -//! tutorial previews its numbered steps. Back goes up one level. `learn.list` lists the tutorials, -//! `learn.show` opens one and `window.learn` shows or hides the panel. +//! tutorial previews its numbered steps. A tool's name in a step is a link that shows where the tool +//! is ([`locate`]). Back goes up one level. `learn.list` lists the tutorials, `learn.show` opens one, +//! `learn.locateTool` shows where a tool is and `window.learn` shows or hides the panel. pub mod catalog; +pub mod locate; use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; @@ -86,7 +88,7 @@ fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { app.ui.learn.skill = Some(skill.id); app.ui.learn.tutorial = None; } - steps(ui, tutorial); + steps(app, ui, tutorial); return; } if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { @@ -113,18 +115,52 @@ fn back(ui: &mut Ui) -> bool { crate::widgets::icon_button(ui, "chevron-left", tl!("Back"), false, 22.0).clicked() } -/// A tutorial's title and its numbered steps. -fn steps(ui: &mut Ui, tutorial: &catalog::Tutorial) { +/// A tutorial's title and its numbered steps. The tool's name in a step is a blue link: a click, or +/// Enter or Space while it has the keyboard focus, shows where the tool is (`learn.locateTool`). +fn steps(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &catalog::Tutorial) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tutorial.title).size(13.0).color(t.text_strong)); ui.add_space(4.0); + let mut locate = None; for (i, step) in tutorial.steps.iter().enumerate() { ui.horizontal_top(|ui| { ui.label(egui::RichText::new(format!("{}.", i + 1)).color(t.text_dim)); - ui.add(egui::Label::new(egui::RichText::new(step.text).color(t.text)).wrap()); + let (Some(tool), Some((before, name, after))) = (step.tool, step.tool_name()) else { + ui.add(egui::Label::new(egui::RichText::new(step.text).color(t.text)).wrap()); + return; + }; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + if !before.is_empty() { + ui.add(egui::Label::new(egui::RichText::new(before).color(t.text)).wrap()); + } + if tool_link(ui, &t, name).clicked() { + locate = Some(tool); + } + if !after.is_empty() { + ui.add(egui::Label::new(egui::RichText::new(after).color(t.text)).wrap()); + } + }); }); ui.add_space(2.0); } + if let Some(tool) = locate { + if let Err(e) = app.run("learn.locateTool", json!({ "tool": tool })) { + app.status(e); + } + ui.ctx().request_repaint(); + } +} + +/// A tool's name in a step, drawn as a link: blue and underlined, with an outline while it has the +/// keyboard focus. +fn tool_link(ui: &mut Ui, t: &Tokens, name: &str) -> egui::Response { + let resp = ui.add(egui::Label::new(egui::RichText::new(name).color(t.selection).underline()).sense(Sense::click())); + if resp.has_focus() { + ui.painter().rect_stroke(resp.rect.expand(2.0), egui::CornerRadius::same(2), Stroke::new(1.0, t.selection), egui::StrokeKind::Outside); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Link, ui.is_enabled(), name)); + resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tl!("Show where this tool is")) } /// A clickable row: `title`, and for a skill (`tutorials`) a progress ring before it and the @@ -155,12 +191,13 @@ fn row(ui: &mut Ui, title: &str, tutorials: Option) -> egui::Response { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } -/// The Learn panel's UI commands: `window.learn`, `learn.list` and `learn.show`. +/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.show` and `learn.locateTool`. pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { Some(match id { "window.learn" => toggle(app, p), "learn.list" => Ok(list()), "learn.show" => open(app, p), + "learn.locateTool" => locate::locate_tool(app, p), _ => return None, }) } @@ -206,7 +243,7 @@ fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let (skill, tutorial) = catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)"))?; crate::panel_docking::reveal_panel(app, ID)?; app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; - let steps: Vec<&str> = tutorial.steps.iter().map(|s| s.text).collect(); + let steps: Vec = tutorial.steps.iter().map(catalog::Step::plain).collect(); Ok(json!({"skill": skill.id, "tutorial": tutorial.id, "title": tutorial.title, "steps": steps})) } diff --git a/crates/ui-egui/src/panels/learn/catalog.rs b/crates/ui-egui/src/panels/learn/catalog.rs index b1ffd897d7..98f0d6f355 100644 --- a/crates/ui-egui/src/panels/learn/catalog.rs +++ b/crates/ui-egui/src/panels/learn/catalog.rs @@ -3,6 +3,8 @@ /// One step of a tutorial: what to do, and the tool it chooses or the command it runs, if any. pub struct Step { + /// The step as the panel shows it. In a step that names a tool, the tool's name is in square + /// brackets, and the panel draws it as a link that shows where the tool is. pub text: &'static str, /// A tool id ([`vectorcraft_tools::catalog::tool_info`]) the step names. pub tool: Option<&'static str>, @@ -10,6 +12,22 @@ pub struct Step { pub command: Option<&'static str>, } +impl Step { + /// The text before the tool's name, the name, and the text after it; none for a step that + /// names no tool or has no bracketed name. + pub fn tool_name(&self) -> Option<(&'static str, &'static str, &'static str)> { + self.tool?; + let (before, rest) = self.text.split_once('[')?; + let (name, after) = rest.split_once(']')?; + Some((before, name, after)) + } + + /// The text as it reads, without the brackets around the tool's name. + pub fn plain(&self) -> String { + self.text.replace(['[', ']'], "") + } +} + /// A tutorial: an id for `learn.show`, its title and its numbered steps. pub struct Tutorial { pub id: &'static str, @@ -29,7 +47,7 @@ const fn say(text: &'static str) -> Step { Step { text, tool: None, command: None } } -/// A step that uses tool `id`. +/// A step that uses tool `id`, whose name `text` holds in square brackets. const fn tool(text: &'static str, id: &'static str) -> Step { Step { text, tool: Some(id), command: None } } @@ -49,7 +67,7 @@ pub const SKILLS: &[Skill] = &[ id: "draw-and-color", title: "Draw and color a shape", steps: &[ - tool("Choose the Rectangle tool in the toolbar.", "rectangle"), + tool("Choose the [Rectangle tool] in the toolbar.", "rectangle"), say("Drag on the artboard to draw a rectangle."), say("With the rectangle still selected, click a color in the Swatches panel to fill it."), command("Click the Stroke box under the tools, then click another swatch to color the outline.", "paint.toggleActive"), @@ -59,16 +77,16 @@ pub const SKILLS: &[Skill] = &[ id: "combine-shapes", title: "Combine shapes into one", steps: &[ - tool("Choose the Ellipse tool and draw two circles that overlap.", "ellipse"), - tool("Choose the Selection tool and drag across both circles to select them.", "selection"), - tool("Choose the Shape Builder tool and drag across the parts you want to merge.", "shapeBuilder"), + tool("Choose the [Ellipse tool] and draw two circles that overlap.", "ellipse"), + tool("Choose the [Selection tool] and drag across both circles to select them.", "selection"), + tool("Choose the [Shape Builder tool] and drag across the parts you want to merge.", "shapeBuilder"), ], }, Tutorial { id: "add-headline", title: "Add a headline", steps: &[ - tool("Choose the Type tool and click on the artboard.", "type"), + tool("Choose the [Type tool] and click on the artboard.", "type"), say("Type a few words, then press Escape to finish."), say("Pick a font and a size in the Character panel."), ], @@ -83,7 +101,7 @@ pub const SKILLS: &[Skill] = &[ id: "pen-paths", title: "Draw paths with the Pen", steps: &[ - tool("Choose the Pen tool.", "pen"), + tool("Choose the [Pen tool].", "pen"), say("Click to place corner points, or drag to pull out a curve."), say("Click the first point again to close the shape, or press Enter to leave it open."), ], @@ -92,18 +110,18 @@ pub const SKILLS: &[Skill] = &[ id: "smooth-curves", title: "Draw smooth curves", steps: &[ - tool("Choose the Curvature tool.", "curvature"), + tool("Choose the [Curvature tool].", "curvature"), say("Click to place points: the path bends smoothly through each one."), - tool("Choose the Direct Selection tool and drag a point to reshape the curve.", "directSelection"), + tool("Choose the [Direct Selection tool] and drag a point to reshape the curve.", "directSelection"), ], }, Tutorial { id: "pencil-sketch", title: "Sketch with the Pencil", steps: &[ - tool("Choose the Pencil tool.", "pencil"), + tool("Choose the [Pencil tool].", "pencil"), say("Drag to draw a freehand line."), - tool("Choose the Smooth tool and drag along the line to even it out.", "smooth"), + tool("Choose the [Smooth tool] and drag along the line to even it out.", "smooth"), ], }, ], @@ -116,7 +134,7 @@ pub const SKILLS: &[Skill] = &[ id: "fill-and-stroke", title: "Fill and stroke", steps: &[ - tool("Select an object with the Selection tool.", "selection"), + tool("Select an object with the [Selection tool].", "selection"), say("Click the Fill box under the tools, then pick a color in the Color panel."), command("Click the double arrow beside the boxes to swap the fill and the stroke.", "paint.swap"), ], @@ -125,8 +143,8 @@ pub const SKILLS: &[Skill] = &[ id: "gradient", title: "Add a gradient", steps: &[ - tool("Select an object with the Selection tool.", "selection"), - tool("Choose the Gradient tool and click the object to give it a gradient.", "gradient"), + tool("Select an object with the [Selection tool].", "selection"), + tool("Choose the [Gradient tool] and click the object to give it a gradient.", "gradient"), say("Drag across the object to set the gradient's direction and length."), ], }, @@ -134,8 +152,8 @@ pub const SKILLS: &[Skill] = &[ id: "copy-colors", title: "Copy colors from other art", steps: &[ - tool("Select the object to color with the Selection tool.", "selection"), - tool("Choose the Eyedropper tool and click the art whose colors you want.", "eyedropper"), + tool("Select the object to color with the [Selection tool].", "selection"), + tool("Choose the [Eyedropper tool] and click the art whose colors you want.", "eyedropper"), ], }, ], @@ -148,8 +166,8 @@ pub const SKILLS: &[Skill] = &[ id: "area-type", title: "Flow text into a shape", steps: &[ - tool("Draw a rectangle for the text to fill.", "rectangle"), - tool("Choose the Area Type tool and click the rectangle's edge.", "areaType"), + tool("Draw a rectangle for the text to fill with the [Rectangle tool].", "rectangle"), + tool("Choose the [Area Type tool] and click the rectangle's edge.", "areaType"), say("Type or paste your text: it wraps inside the shape."), ], }, @@ -157,8 +175,8 @@ pub const SKILLS: &[Skill] = &[ id: "type-on-path", title: "Type along a path", steps: &[ - tool("Draw a curved path with the Pen tool.", "pen"), - tool("Choose the Type on a Path tool and click the path.", "typeOnPath"), + tool("Draw a curved path with the [Pen tool].", "pen"), + tool("Choose the [Type on a Path tool] and click the path.", "typeOnPath"), say("Type your text: it follows the path."), ], }, @@ -166,7 +184,7 @@ pub const SKILLS: &[Skill] = &[ id: "outline-type", title: "Turn type into shapes", steps: &[ - tool("Select a text object with the Selection tool.", "selection"), + tool("Select a text object with the [Selection tool].", "selection"), command("Choose Type › Create Outlines to turn its letters into paths.", "type.createOutlines"), ], }, diff --git a/crates/ui-egui/src/panels/learn/locate.rs b/crates/ui-egui/src/panels/learn/locate.rs new file mode 100644 index 0000000000..607da1c45c --- /dev/null +++ b/crates/ui-egui/src/panels/learn/locate.rs @@ -0,0 +1,206 @@ +//! `learn.locateTool`, which the link on a tool's name in a tutorial step runs: it marks the tool +//! where the current layout shows it, without choosing it. A tool shown on its toolbar button has +//! the button marked; a tool in a collapsed group opens the group's flyout with the tool's row +//! marked; a group torn off into a strip is raised with the tool marked in it; a tool on no shown +//! toolbar is marked in the All Tools drawer. The mark lasts [`LIFETIME`] seconds after it first +//! shows, until Escape, or until the next locate replaces it. + +use egui::{Context, CornerRadius, LayerId, Rect, Stroke, StrokeKind}; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::state::UiState; +use crate::theme::Tokens; + +/// Seconds the mark stays on screen after it first shows. +pub const LIFETIME: f64 = 3.0; +/// The width of the mark's outline. +pub const MARK_WIDTH: f32 = 2.0; +/// Passes a locate waits for its mark to draw before it is dropped: the toolbar no longer shows +/// the tool, or the flyout it opened has closed. +const UNSEEN: u8 = 3; + +/// Which control shows the located tool. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Target { + /// The Tools panel's toolbar: a button, a group's flyout or a strip torn off it. + Tools, + /// The toolbar made with New Toolbar called this: a button, a group's flyout or a strip. + Custom(String), + /// The All Tools drawer beside the Tools panel. + AllTools, +} + +/// A tool being located and how far its mark has got. +#[derive(Clone, Debug)] +pub struct Locate { + pub tool: &'static str, + pub target: Target, + /// When the mark first drew (the context's time). + since: Option, + /// The target was scrolled into view. + pub(crate) scrolled: bool, + /// The group's flyout was opened or its strip raised. + pub(crate) revealed: bool, + /// The toolbar made with New Toolbar that holds the tool was brought to the front. + raised: bool, + /// The mark drew in this pass. + drawn: bool, + /// Passes in a row in which the mark did not draw. + unseen: u8, +} + +/// The located tool when the Tools panel's toolbar (`toolbar` none) or the toolbar made with New +/// Toolbar called `toolbar` shows it. +pub(crate) fn wanted(ui: &UiState, toolbar: Option<&str>) -> Option<&'static str> { + let l = ui.learn_locate.as_ref()?; + let here = match (&l.target, toolbar) { + (Target::Tools, None) => true, + (Target::Custom(name), Some(t)) => name == t, + _ => false, + }; + here.then_some(l.tool) +} + +/// The located tool when the All Tools drawer beside the Tools panel shows it. +pub(crate) fn wanted_in_drawer(ui: &UiState) -> Option<&'static str> { + let l = ui.learn_locate.as_ref()?; + (l.target == Target::AllTools && ui.tools_drawer && ui.drawer_toolbar.is_none()).then_some(l.tool) +} + +/// Whether the target is still to be scrolled into view; true once. +pub(crate) fn scroll_once(ui: &mut UiState) -> bool { + ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.scrolled, true)) +} + +/// Whether the target's flyout is still to be opened or its strip raised; true once. +pub(crate) fn reveal_once(ui: &mut UiState) -> bool { + ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.revealed, true)) +} + +/// Whether the toolbar made with New Toolbar that holds the tool is still to be brought to the +/// front; true once. +pub(crate) fn raise_once(ui: &mut UiState) -> bool { + ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.raised, true)) +} + +/// Whether a locate is under way and its mark has not drawn yet. +pub(crate) fn unmarked(ui: &UiState) -> bool { + ui.learn_locate.as_ref().is_some_and(|l| l.since.is_none()) +} + +/// Mark `rect`, the located tool's control as drawn this pass on `layer`, clipped to `clip` and the +/// window. The first mark starts the [`LIFETIME`]; once it has passed, the locate ends. +pub(crate) fn mark(app: &mut VectorcraftApp, ctx: &Context, layer: LayerId, clip: Rect, rect: Rect) { + let Some(l) = app.ui.learn_locate.as_mut() else { return }; + let now = ctx.input(|i| i.time); + let since = *l.since.get_or_insert(now); + let left = LIFETIME - (now - since); + if left <= 0.0 { + app.ui.learn_locate = None; + return; + } + l.drawn = true; + ctx.request_repaint_after_secs(left as f32); + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(layer).with_clip_rect(clip.intersect(ctx.content_rect())); + painter.rect_stroke(rect.shrink(1.0), CornerRadius::same(6), Stroke::new(MARK_WIDTH, t.selection), StrokeKind::Inside); +} + +/// After the toolbars and panels have drawn: Escape ends the locate, also an Escape that closed the +/// All Tools drawer or ended something else first, and so does a mark that has not drawn for +/// [`UNSEEN`] passes. +pub(crate) fn tidy(app: &mut VectorcraftApp, ctx: &Context) { + let Some(l) = app.ui.learn_locate.as_mut() else { return }; + // The raw events keep a key that a handler consumed this pass. + let escape = ctx.input(|i| i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, .. }))); + if escape { + app.ui.learn_locate = None; + return; + } + if std::mem::take(&mut l.drawn) { + l.unseen = 0; + } else { + l.unseen += 1; + if l.unseen > UNSEEN { + app.ui.learn_locate = None; + } + } +} + +/// The reply for a tool that no toolbar can show now, with what would show it. +fn unavailable(tool: &str, reason: &str) -> Value { + json!({"tool": tool, "location": "unavailable", "toolbar": null, "reason": reason}) +} + +/// Mark `tool` on `target` from the next pass, replacing an earlier locate; the flyout or drawer an +/// earlier one opened closes. +fn start(app: &mut VectorcraftApp, tool: &'static str, target: Target) { + if let Some(old) = app.ui.learn_locate.take() { + app.ui.flyout = None; + if old.target == Target::AllTools && target != Target::AllTools && app.ui.drawer_toolbar.is_none() { + app.ui.tools_drawer = false; + } + } + app.ui.learn_locate = Some(Locate { tool, target, since: None, scrolled: false, revealed: false, raised: false, drawn: false, unseen: 0 }); +} + +/// `learn.locateTool {tool}`: mark where `tool` is in the current layout → +/// `{tool, location, toolbar}`, where `location` is `toolbar` (its button), `flyout` (a row of its +/// collapsed group's flyout, which opens), `strip` (its group torn off into a strip, which comes +/// to the front), `allTools` (no shown toolbar holds it: the All Tools drawer opens) or +/// `unavailable` (with `reason`), and `toolbar` names the toolbar made with New Toolbar that +/// holds it (null for the Tools panel's). A Tools panel that Window › Tools hid shows again; the +/// tool is not chosen, and the remembered tool of its group stays. A missing, non-string or unknown +/// id is an error and changes nothing. +pub(crate) fn locate_tool(app: &mut VectorcraftApp, p: &Value) -> Result { + let key = "tool"; + let tool = match p.get(key) { + None | Some(Value::Null) => return Err("tool (a tool id) is required".into()), + Some(Value::String(tool)) => tool.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + let tool = vectorcraft_tools::tool_info(tool).ok_or_else(|| format!("unknown tool `{tool}`"))?.id; + if app.ui.screen_mode >= 3 { + return Ok(unavailable(tool, "Presentation Mode hides the toolbars: change the screen mode to show them")); + } + // Tab hides the toolbars made with New Toolbar until the next Tab, also once Window › Tools + // shows the Tools panel again. + let tab_hid = app.ui.tab_hid.is_some(); + if tab_hid && !app.ui.toolbar { + return Ok(unavailable(tool, "Tab has hidden the toolbars: press Tab to show them")); + } + if let Some(slot) = crate::toolbar::slots(app).into_iter().map(|(_, s)| s).find(|s| s.contains(&tool)) { + let location = if slot.len() > 1 && crate::toolbar::is_floating(app, None, slot[0]) { + "strip" + } else if crate::toolbar::shown_tool(app, &slot) == tool { + "toolbar" + } else { + "flyout" + }; + app.ui.toolbar = true; + start(app, tool, Target::Tools); + return Ok(json!({"tool": tool, "location": location, "toolbar": null})); + } + let active = app.session.tool_id(); + let custom = app.ui.custom_toolbars.iter().filter(|b| b.shown && !tab_hid).find_map(|bar| { + let slot = bar.slots.iter().find(|s| s.iter().any(|t| t == tool))?; + let tools: Vec<&'static str> = slot.iter().filter_map(|id| vectorcraft_tools::tool_info(id).map(|t| t.id)).collect(); + let first = *tools.first()?; + let location = if tools.len() > 1 && crate::toolbar::is_floating(app, Some(&bar.name), first) { + "strip" + } else if crate::custom_toolbars::shown_tool(bar, active, &tools) == Some(tool) { + "toolbar" + } else { + "flyout" + }; + Some((bar.name.clone(), location)) + }); + if let Some((name, location)) = custom { + start(app, tool, Target::Custom(name.clone())); + return Ok(json!({"tool": tool, "location": location, "toolbar": name})); + } + app.run("window.editToolbar", json!({"open": true}))?; + start(app, tool, Target::AllTools); + Ok(json!({"tool": tool, "location": "allTools", "toolbar": null})) +} diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs index d4dbf8f00b..7014785eda 100644 --- a/crates/ui-egui/src/panels/learn/tests.rs +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -72,8 +72,20 @@ fn the_catalog_is_valid() { assert!(!tutorial.title.is_empty() && !tutorial.steps.is_empty(), "tutorial {} is empty", tutorial.id); for step in tutorial.steps { assert!(step.text.ends_with('.'), "{}: {:?}", tutorial.id, step.text); + let brackets = step.text.matches(['[', ']']).count(); if let Some(tool) = step.tool { - assert!(vectorcraft_tools::catalog::tool_info(tool).is_some(), "{}: no tool {tool}", tutorial.id); + let info = vectorcraft_tools::catalog::tool_info(tool); + assert!(info.is_some(), "{}: no tool {tool}", tutorial.id); + // The step's link names the tool it locates, once. + let name = step.tool_name().map(|(_, name, _)| name); + assert!( + brackets == 2 && name.zip(info).is_some_and(|(n, t)| n.eq_ignore_ascii_case(t.label)), + "{}: {:?}", + tutorial.id, + step.text + ); + } else { + assert_eq!(brackets, 0, "{}: {:?} links no tool", tutorial.id, step.text); } if let Some(cmd) = step.command { let known = @@ -189,7 +201,9 @@ fn a_tutorial_previews_its_steps() { assert_eq!(app.ui.learn.tutorial, Some(tutorial.id)); let text = painted_text(&mut app, show); for (i, step) in tutorial.steps.iter().enumerate() { - assert!(text.contains(&format!("{}.", i + 1)) && text.contains(step.text), "{} in {text}", step.text); + let parts = step.tool_name().map_or(vec![step.text], |(before, name, after)| vec![before, name, after]); + assert!(text.contains(&format!("{}.", i + 1)) && parts.iter().all(|p| text.contains(p)), "{} in {text}", step.text); + assert!(!text.contains('['), "no brackets: {text}"); } click(&mut app, &ctx, "Back", show); assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }); @@ -229,3 +243,92 @@ fn learn_show_rejects_a_missing_or_unknown_tutorial() { assert!(!shown(&app)); } } + +/// The Pen tutorial's steps, as the panel shows them. +fn pen_paths(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + app.ui.learn = View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("pen-paths") }; + show(app, ui); +} + +/// A step's tool name is a link; a click on it locates that tool without choosing it, and +/// `learn.show` gives the step's text without the link's brackets. +#[test] +fn a_tool_name_in_a_step_is_a_link_that_locates_the_tool() { + let mut app = app(); + let ctx = context(); + let nodes = frame(&mut app, &ctx, vec![], pen_paths); + let link = nodes.iter().find(|n| n.label().is_some_and(|l| l == "Pen tool")).expect("the Pen tool link"); + assert_eq!(link.role(), egui::accesskit::Role::Link); + assert_eq!(nodes.iter().filter(|n| n.role() == egui::accesskit::Role::Link).count(), 1, "only the step that names a tool has a link"); + click(&mut app, &ctx, "Pen tool", pen_paths); + let located = app.ui.learn_locate.as_ref().map(|l| (l.tool, l.target.clone())); + assert_eq!(located, Some(("pen", super::locate::Target::Tools)), "{}", app.ui.status); + assert_eq!(app.session.tool_id(), "selection", "the link does not choose the tool"); + let shown_tutorial = app.run("learn.show", json!({"tutorial": "pen-paths"})).unwrap(); + assert_eq!(shown_tutorial["steps"][0], "Choose the Pen tool."); +} + +/// Enter on the link, once Tab has moved the keyboard focus to it, locates the tool. +#[test] +fn enter_on_a_focused_tool_link_locates_the_tool() { + let mut app = app(); + let ctx = context(); + let link = frame(&mut app, &ctx, vec![], pen_paths) + .into_iter() + .find(|n| n.label().is_some_and(|l| l == "Pen tool")) + .and_then(|n| n.bounds()) + .expect("the Pen tool link"); + let key = |key, pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let focused_on_link = |ctx: &egui::Context| { + ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).is_some_and(|r| { + let c = r.rect.center(); + ((link.x0 + link.x1) / 2.0 - f64::from(c.x)).abs() < 1.0 && ((link.y0 + link.y1) / 2.0 - f64::from(c.y)).abs() < 1.0 + }) + }; + let mut found = false; + for _ in 0..30 { + frame(&mut app, &ctx, vec![key(egui::Key::Tab, true), key(egui::Key::Tab, false)], pen_paths); + if focused_on_link(&ctx) { + found = true; + break; + } + } + assert!(found, "Tab reaches the link"); + assert!(app.ui.learn_locate.is_none()); + frame(&mut app, &ctx, vec![key(egui::Key::Enter, true), key(egui::Key::Enter, false)], pen_paths); + frame(&mut app, &ctx, vec![], pen_paths); + assert_eq!(app.ui.learn_locate.as_ref().map(|l| l.tool), Some("pen")); +} + +/// A missing, mistyped or unknown tool id is an error and changes nothing. +#[test] +fn learn_locate_tool_rejects_a_missing_or_unknown_tool() { + let mut app = app(); + app.ui.toolbar = false; + for (params, error) in [ + (json!({}), "tool (a tool id) is required"), + (json!({"tool": null}), "tool (a tool id) is required"), + (json!({"tool": 3}), "`tool` must be a string"), + (json!({"tool": "nope"}), "unknown tool `nope`"), + ] { + assert_eq!(app.run("learn.locateTool", params).unwrap_err(), error); + assert!(app.ui.learn_locate.is_none() && !app.ui.toolbar && !app.ui.tools_drawer && app.ui.flyout.is_none()); + } +} + +/// With the toolbars hidden by Presentation Mode or by Tab, nothing can be marked: the reply says +/// so and the screen stays as it is. +#[test] +fn learn_locate_tool_is_unavailable_while_the_toolbars_are_hidden() { + let mut app = app(); + app.ui.screen_mode = 3; + let reply = app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(); + assert_eq!((reply["location"].as_str(), reply["reason"].is_string()), (Some("unavailable"), true), "{reply}"); + assert!(app.ui.learn_locate.is_none()); + app.ui.screen_mode = 0; + app.run("window.dock", json!({})).unwrap(); + assert!(app.ui.tab_hid.is_some() && !app.ui.toolbar); + let reply = app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(); + assert_eq!(reply["location"], "unavailable", "{reply}"); + assert!(app.ui.learn_locate.is_none() && !app.ui.toolbar && app.ui.tab_hid.is_some(), "Tab's state stays"); +} diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index cabca387ae..7ad184ea99 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -877,6 +877,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // Inseparable pairs stay on one line; only with a kinsoku set. + let (kinsoku_on, bunri) = bunri_kinshi_item(app); + if menu_item(ui, tl!("Bunri-Kinshi"), has && kinsoku_on, bunri) { + toggle_bunri_kinshi(app); + } // Korean lines break between any two syllables, not only between words. let anywhere = shared_para(app, |p| p.korean_line_break_anywhere) == Some(true); if menu_item(ui, tl!("Korean Line Break Anywhere"), has, anywhere) { @@ -904,6 +909,19 @@ fn set_kinsoku_type(app: &mut VectorcraftApp, key: &str) { format(app, json!({"kinsokuType": key})); } +/// Bunri-Kinshi's menu item for the paragraphs the panel acts on ([`format`]): (available, checked). +/// It is available when none of them has kinsoku None, and checked when every one of them has it. +fn bunri_kinshi_item(app: &VectorcraftApp) -> (bool, bool) { + (shared_para(app, |p| p.kinsoku == Kinsoku::None) == Some(false), shared_para(app, |p| p.bunri_kinshi) == Some(true)) +} + +/// Bunri-Kinshi (Paragraph panel menu): a click turns it off for all the paragraphs the panel acts +/// on ([`format`]) when every one of them has it, and on for all of them otherwise, in one undo step. +fn toggle_bunri_kinshi(app: &mut VectorcraftApp) { + let on = shared_para(app, |p| p.bunri_kinshi) == Some(true); + format(app, json!({"bunriKinshi": !on})); +} + /// Korean Line Break Anywhere (Paragraph panel menu): the menu item is checked when every paragraph /// the panel acts on ([`format`]) has it, and a click turns it off for all of them then, and on for /// all of them otherwise, in one undo step. @@ -915,8 +933,8 @@ fn toggle_korean_line_break_anywhere(app: &mut VectorcraftApp) { /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification /// options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi -/// Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman -/// Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo +/// Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Bunri-Kinshi, +/// Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo /// step named Reset Panel (inside an undo group that is already open, as part of its step). fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); @@ -937,7 +955,8 @@ fn reset_panel(app: &mut VectorcraftApp) { "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", "direction": "auto", "leadingModel": "romanBaseline", "mojikumi": "lineEndHalf", "burasagari": "standard", "kinsoku": "hard", - "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false, "koreanLineBreakAnywhere": false + "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false, "koreanLineBreakAnywhere": false, + "bunriKinshi": false }), ); para_cmd(app, "text.tabs.clear", json!({})); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 517ef1eeab..177ecd08fb 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -166,6 +166,48 @@ fn korean_line_break_anywhere_toggles_the_selected_paragraphs_in_one_undo_step() assert_eq!(flags(&app), (false, false), "Reset Panel turns it off"); } +/// Bunri-Kinshi: new type has it off; the menu item turns it on for the paragraphs the Type tool's +/// selection touches in one undo step, is unchecked while the selected paragraphs differ, and is +/// unavailable while any of them has kinsoku None, which keeps the setting. Reset Panel turns it off. +#[test] +fn bunri_kinshi_toggle_follows_kinsoku_and_the_selected_paragraphs() { + let (mut app, id) = editing_text("あ——い\nう……え\n"); + app.session.prefs.show_east_asian_options = true; + let flags = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).bunri_kinshi, text.para_at(1).bunri_kinshi) + }; + assert_eq!(flags(&app), (false, false), "new type has it off"); + select_range(&mut app, 0, 3); + assert_eq!(bunri_kinshi_item(&app), (true, false)); + let history = app.session.active().unwrap().history.undo.len(); + toggle_bunri_kinshi(&mut app); + assert_eq!(flags(&app), (true, false)); + assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); + assert_eq!(bunri_kinshi_item(&app), (true, true)); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(bunri_kinshi_item(&app), (true, false), "mixed: unchecked"); + toggle_bunri_kinshi(&mut app); + assert_eq!(flags(&app), (true, true)); + // Kinsoku None on one paragraph: the item is unavailable and the setting stays. + let second = "あ——い\n".len(); + app.run("text.setFormat", json!({"id": id, "start": second, "end": second, "kinsoku": "none"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(bunri_kinshi_item(&app), (false, true)); + assert_eq!(flags(&app), (true, true)); + app.run("text.setFormat", json!({"id": id, "kinsoku": "soft"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(bunri_kinshi_item(&app), (true, true)); + toggle_bunri_kinshi(&mut app); + assert_eq!(flags(&app), (false, false)); + app.session.set_tool_option("selectAll", &json!(true)); + toggle_bunri_kinshi(&mut app); + app.session.set_tool_option("selectAll", &json!(true)); + reset_panel(&mut app); + assert_eq!(flags(&app), (false, false), "Reset Panel turns it off"); +} + #[test] fn paragraph_mixed_indent_fields_show_blank() { let (mut app, id) = editing_text("A\nB\n"); diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index f6ece0ac55..894b8488f8 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -100,10 +100,37 @@ pub(crate) fn edited_dash_pattern(fields: &[Option; 6], previous: &[f64]) - out } -fn arrow_label(a: Option) -> &'static str { +fn arrow_label(a: Option<&Arrowhead>) -> &'static str { a.map_or("None", Arrowhead::label) } +/// The name an arrowhead menu shows for `a`: a built-in head's in the interface language, a +/// custom head's own. +fn arrow_name(a: Option<&Arrowhead>) -> String { + match a { + Some(Arrowhead::Custom(c)) => c.name.clone(), + _ => tl!(arrow_label(a)).to_string(), + } +} + +/// The preview cache key of `a`: its name, and for a custom head its art. +fn arrow_key(a: Option<&Arrowhead>) -> String { + match a { + Some(Arrowhead::Custom(c)) => { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for s in &c.path.subpaths { + (s.anchors.len(), s.closed).hash(&mut h); + for p in s.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]) { + (p.x.to_bits(), p.y.to_bits()).hash(&mut h); + } + } + format!("custom:{}:{:x}", c.name, h.finish()) + } + _ => format!("{a:?}"), + } +} + /// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the /// selected characters' stroke. fn set(app: &mut VectorcraftApp, p: Value) { @@ -272,14 +299,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); widgets::divider(ui); // Arrowheads. - let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow), st.as_ref().and_then(|s| s.end_arrow)); + let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow.clone()), st.as_ref().and_then(|s| s.end_arrow.clone())); + // The custom heads of the arrowheads file follow the built-in ones. + let custom: Vec = app.session.arrowheads.heads().iter().cloned().map(Arrowhead::Custom).collect(); ui.horizontal(|ui| { row_label(ui, tl!("Arrowheads:")); - if let Some(a) = arrow_dropdown(ui, "arrow-start", sa, true) { - set(app, json!({"startArrow": a.map(|a| format!("{a:?}"))})); + // A custom head goes with its art, as document.inspect reports it. + if let Some(a) = arrow_dropdown(ui, "arrow-start", sa.as_ref(), &custom, true) { + set(app, json!({ "startArrow": a })); } - if let Some(a) = arrow_dropdown(ui, "arrow-end", ea, false) { - set(app, json!({"endArrow": a.map(|a| format!("{a:?}"))})); + if let Some(a) = arrow_dropdown(ui, "arrow-end", ea.as_ref(), &custom, false) { + set(app, json!({ "endArrow": a })); } if widgets::icon_button_enabled(ui, "arrow-left-right", tl!("Swap start and end arrowheads"), false, st.is_some(), 22.0).clicked() { app.run("stroke.setAdvanced", json!({"swapArrows": true})).ok(); @@ -337,22 +367,44 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } /// The document an arrowhead preview renders: a `w`×`h` pt line with head `a` at its left -/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted). -pub(super) fn arrow_doc(a: Option, start: bool, w: f64, h: f64) -> Option { - let mut doc = Document::new(w, h); - let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 1.5); - let st = ap.stroke_mut()?; +/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted). A custom +/// head is made smaller than the built-in ones when it would not fit (below the smallest head +/// weight, its outline is scaled down), and the line's end moves in by as much as the head reaches +/// past its tip. +pub(super) fn arrow_doc(mut a: Option, start: bool, w: f64, h: f64) -> Option { + const WIDTH: f64 = 1.5; + // The smallest weight `StrokeLayer::arrow_weight` gives a head. + const LEAST: f64 = 0.25; // Heads of weight 2.5 pt: 10 pt long and wide. - st.arrow_scale = (500.0 / 3.0, 500.0 / 3.0); + let mut scale: f64 = 500.0 / 3.0; + let mut inset = 0.0; + if let Some(Arrowhead::Custom(c)) = &a + && let Some(b) = c.path.control_bounds() + { + let (long, half) = (b.x1.max(0.0) - b.x0.min(0.0), b.y0.abs().max(b.y1.abs())); + let fit = |room: f64, units: f64| if units > 1e-9 { room.max(0.0) / units } else { f64::INFINITY }; + let weight = fit(w - 6.0, long).min(fit(h / 2.0 - 1.0, half)).min(2.5); + inset = -b.x0.min(0.0) * weight; + if weight < LEAST { + let path = c.path.transformed(vectorcraft_geom::Affine::scale(weight / LEAST)); + a = Some(Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(c.name.clone(), path)))); + } + scale = scale.min(weight.max(LEAST) / WIDTH * 100.0); + } + let mut doc = Document::new(w, h); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), WIDTH); + let st = ap.stroke_mut()?; + st.arrow_scale = (scale, scale); st.arrow_align = ArrowAlign::Tip; if start { st.start_arrow = a } else { st.end_arrow = a } + let (x0, x1) = if start { (3.0 + inset, w - 3.0) } else { (3.0, w - 3.0 - inset) }; let mut bp = vectorcraft_geom::BezPath::new(); - bp.move_to((3.0, h / 2.0)); - bp.line_to((w - 3.0, h / 2.0)); + bp.move_to((x0, h / 2.0)); + bp.line_to((x1, h / 2.0)); let (id, l) = (doc.alloc_id(), doc.layers[0].id); doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?; Some(doc) @@ -360,14 +412,15 @@ pub(super) fn arrow_doc(a: Option, start: bool, w: f64, h: f64) -> Op /// Draw an arrowhead preview (the head as the canvas draws it) in `r`: a line with the head at the /// left (`start`) or right end. -fn paint_arrow(ui: &Ui, r: Rect, a: Option, start: bool, color: Color32) { - if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, start, w, h)) { +fn paint_arrow(ui: &Ui, r: Rect, a: Option<&Arrowhead>, start: bool, color: Color32) { + if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{}:{start}", arrow_key(a)), r.size(), |w, h| arrow_doc(a.cloned(), start, w, h)) { ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), color); } } -/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice). -fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> Option> { +/// Dropdown whose button and items show drawn arrowhead previews: None, the built-in heads, then +/// the `custom` ones. Returns Some(choice). +fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<&Arrowhead>, custom: &[Arrowhead], start: bool) -> Option> { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(64.0, 24.0), Sense::click()); ui.painter().rect_filled(r, 2, t.input); @@ -380,23 +433,27 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> egui::Popup::menu(&resp).id(egui::Id::unique(("arrow-pop", id))).show(|ui| { ui.set_min_width(170.0); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for a in std::iter::once(None).chain(Arrowhead::ALL.map(Some)) { + let built_in = Arrowhead::ALL; + for a in std::iter::once(None).chain(built_in.iter().map(Some)).chain(custom.iter().map(Some)) { let (row, rr) = ui.allocate_exact_size(vec2(170.0, 22.0), Sense::click()); if a == cur { ui.painter().rect_filled(row, 0.0, t.row_selected); } else if rr.hovered() { ui.painter().rect_filled(row, 0.0, t.hover); } - paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong); + // Only the rows in view render their previews. + if ui.is_rect_visible(row) { + paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong); + } ui.painter().text( row.left_center() + vec2(66.0, 0.0), egui::Align2::LEFT_CENTER, - tl!(arrow_label(a)), + arrow_name(a), egui::FontId::proportional(11.5), t.text, ); if rr.clicked() { - out = Some(a); + out = Some(a.cloned()); ui.close(); } } @@ -500,6 +557,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Reset Profiles"), crate::menus::enabled(app, "stroke.widthProfile.reset"), false) { app.run("stroke.widthProfile.reset", json!({})).ok(); } + ui.separator(); + // Opens the arrowheads file, whose symbols are the custom heads; saving it reloads them. + if menu_item(ui, tl!("Edit Arrowheads…"), crate::menus::enabled(app, "stroke.arrowheads.edit"), false) { + app.run("stroke.arrowheads.edit", json!({})).ok(); + } } #[cfg(test)] @@ -647,7 +709,7 @@ mod tests { assert!(silhouette(WidthProfile::PRESETS[0].points).is_none(), "uniform draws the plain bar"); let lens = WidthProfile::lens(); assert_eq!(silhouette(&lens.points), Some(lens.points.as_slice())); - assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)"); + assert_eq!(arrow_label(Some(&Arrowhead::CircleOpen)), "Circle (open)"); } #[test] diff --git a/crates/ui-egui/src/panels/swatches/dnd.rs b/crates/ui-egui/src/panels/swatches/dnd.rs index 22b542a3f0..d4360b4743 100644 --- a/crates/ui-egui/src/panels/swatches/dnd.rs +++ b/crates/ui-egui/src/panels/swatches/dnd.rs @@ -59,9 +59,10 @@ pub(super) enum Drop { /// `target` of its kind (neither None, Registration nor a tint swatch; see [`replaces`]), it /// gives that swatch its paint instead of moving. Move { names: Vec, target: Option, after: bool, with: Option }, - /// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row - /// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch - /// `target` when that is of its kind and not a tint swatch. + /// A paint from elsewhere (a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's + /// thumbnail) dropped on row `target` (or between rows) becomes a swatch; with Alt held + /// (`replace`) it replaces swatch `target` when that is of its kind and neither Registration + /// nor a tint swatch. New { paint: Paint, target: Option, replace: bool }, /// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays). Pattern { ids: Vec }, @@ -259,7 +260,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { None => return, }, // Alt: an edited gradient (or colour) replaces the swatch it is dropped on. - Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => { + Drop::New { paint, target: Some(t), replace: true } + if d.swatch(&t).is_some_and(|w| !w.is_reserved() && w.tint_of().is_none() && same_kind(&w.paint, &paint)) => + { ("swatch.edit", json!({"name": t, "paint": crate::panels::swatch_paint_params(&paint)})) } Drop::New { paint, target, .. } => { @@ -279,9 +282,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { } /// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches, -/// a Fill/Stroke proxy, the Gradient panel's thumbnail), the fill of the object whose appearance -/// the Appearance panel's thumbnail carries (or of the first art dragged off the canvas), or the -/// thumbnail of a dragged graphic style. +/// a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's thumbnail), the fill of the +/// object whose appearance the Appearance panel's thumbnail carries (or of the first art dragged +/// off the canvas), or the thumbnail of a dragged graphic style. pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { let Some(d) = egui::DragAndDrop::payload::(ctx) else { return }; let fill_of = |id: &vectorcraft_doc::NodeId| app.session.active().and_then(|st| st.doc.node(*id)).map(|n| n.appearance.fill_paint()); diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index c649e282ba..3586f91607 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -437,11 +437,24 @@ fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, tim /// [`drag`] with `modifiers` held. fn drag_with(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, modifiers: Modifiers, time: f64) { + drag_drawn(app, ctx, from, to, modifiers, time, show); +} + +/// [`drag_with`] over the panels `draw` draws. +fn drag_drawn( + app: &mut VectorcraftApp, + ctx: &egui::Context, + from: Pos2, + to: Pos2, + modifiers: Modifiers, + time: f64, + draw: fn(&mut VectorcraftApp, &mut Ui), +) { let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; - frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, show); - frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, show); - frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, show); - frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, show); + frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, draw); + frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, draw); + frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, draw); + frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, draw); } fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec { @@ -553,6 +566,124 @@ fn a_proxy_dropped_on_the_panel_becomes_a_swatch() { assert!(egui::DragAndDrop::payload::(&ctx).is_none()); } +/// The center of the Recent Colors chip `i` drawn by `draw` in a frame at `time`: the chip of the +/// panel's row, or (`color`) of the Color panel's row, drawn in a child whose scope id `draw` keeps +/// in the panel state `"test-color-scope"`. +fn recent_chip(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui), color: bool) -> Pos2 { + let mut chip = None; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), screen_rect, ..Default::default() }, |ui| { + draw(app, ui); + let scope = if color { pstate::>(ctx, "test-color-scope").expect("the Color panel is drawn") } else { ui.scope_id() }; + chip = ctx.read_response(scope.with(("recent", i))).map(|r| r.rect.center()); + }); + out.textures_delta.clear(); + chip.unwrap_or_else(|| panic!("recent chip {i} is drawn")) +} + +/// A rectangle filled with a CMYK color and then an RGB one, so Recent Colors holds the RGB color +/// first and the CMYK one second; → its id and the CMYK color. +fn art_with_two_recent_colors(app: &mut VectorcraftApp) -> (vectorcraft_doc::NodeId, Color) { + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "color": {"c": 10, "m": 60, "y": 20, "k": 5}, "keepModel": true})).unwrap(); + app.run("paint.setFill", json!({"ids": [id], "color": "#2050a0"})).unwrap(); + let cmyk = app.session.recent_colors[1]; + assert!(matches!(cmyk, Color::Cmyk { .. }), "the second recent color is CMYK: {cmyk:?}"); + (vectorcraft_doc::NodeId(id), cmyk) +} + +/// The fill of art `id`. +fn fill_of(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> Option { + app.session.doc().unwrap().doc.node(id).map(|n| n.appearance.fill_paint()) +} + +/// A Recent Colors chip dragged onto the swatch list becomes a swatch of its color, in its color +/// model, as one undo step; the drag paints no art, and a color that is already a swatch is added +/// again under another name. +#[test] +fn a_recent_color_dragged_onto_the_list_becomes_a_swatch() { + let mut app = app(); + let ctx = context(); + let (id, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, show); + let art = fill_of(&app, id); + let undo = app.session.doc().unwrap().history.undo.len(); + let (count, brights) = (app.session.doc().unwrap().doc.swatches_iter().count(), names_of(&app, Some("Brights")).len()); + // Onto a grouped swatch: into that group, at its end. + let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false); + drag(&mut app, &ctx, chip, tile_center(&ctx, "Bright Red"), 1.0); + let d = app.session.doc().unwrap(); + let group = &d.doc.swatch_groups.iter().find(|g| g.name == "Brights").unwrap().swatches; + assert_eq!(group.len(), brights + 1, "{}", app.ui.status); + let first = group.last().cloned().expect("a swatch"); + assert_eq!(first.paint, Paint::solid(cmyk), "the chip's color, still CMYK"); + assert_eq!(d.history.undo.len(), undo + 1, "one undo step"); + assert_eq!(fill_of(&app, id), art, "the drag paints no art"); + assert!(egui::DragAndDrop::payload::(&ctx).is_none()); + // The same color again, onto an ungrouped swatch: a second swatch under another name. + frame(&mut app, &ctx, vec![], 2.0, show); + let chip = recent_chip(&mut app, &ctx, 1, 2.5, show, false); + drag(&mut app, &ctx, chip, tile_center(&ctx, "Red"), 3.0); + let newest = app.session.doc().unwrap().doc.swatches.last().cloned().expect("a swatch"); + assert_eq!(newest.paint, Paint::solid(cmyk)); + assert_ne!(newest.name, first.name, "another name"); + assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 2); + // Undo takes the last one off. + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 1); +} + +/// A color Alt-dropped on Registration, which can't be edited, becomes a new swatch and leaves +/// Registration as it is. +#[test] +fn a_color_alt_dropped_on_registration_becomes_a_new_swatch() { + let mut app = app(); + let ctx = context(); + let (_, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, show); + let registration = app.session.doc().unwrap().doc.swatch(REGISTRATION).map(|w| w.paint.clone()); + let count = app.session.doc().unwrap().doc.swatches_iter().count(); + let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false); + drag_with(&mut app, &ctx, chip, tile_center(&ctx, REGISTRATION), Modifiers::ALT, 1.0); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status); + assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk))); + assert_eq!(d.swatch(REGISTRATION).map(|w| w.paint.clone()), registration, "Registration is unchanged"); +} + +/// A Recent Colors chip of the Color panel dragged onto the Swatches panel becomes a swatch; a click +/// on it still paints the selected art. +#[test] +fn a_recent_color_dragged_from_the_color_panel_becomes_a_swatch() { + fn both(app: &mut VectorcraftApp, ui: &mut Ui) { + show(app, ui); + ui.push_id("color", |ui| { + crate::panels::color::show(app, ui); + set_pstate(ui.ctx(), "test-color-scope", Some(ui.scope_id())); + }); + } + let mut app = app(); + let ctx = context(); + app.ui.color_show_recent_colors = true; + let (id, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, both); + let count = app.session.doc().unwrap().doc.swatches_iter().count(); + let art = fill_of(&app, id); + let chip = recent_chip(&mut app, &ctx, 1, 0.5, both, true); + drag_drawn(&mut app, &ctx, chip, tile_center(&ctx, "Red"), Modifiers::NONE, 1.0, both); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status); + assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk))); + assert_eq!(fill_of(&app, id), art, "the drag paints no art"); + // A click on the chip paints the selected art. + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + frame(&mut app, &ctx, vec![], 2.0, both); + let chip = recent_chip(&mut app, &ctx, 1, 2.5, both, true); + let button = |pressed| Event::PointerButton { pos: chip, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + frame(&mut app, &ctx, vec![Event::PointerMoved(chip), button(true), button(false)], 3.0, both); + assert_eq!(fill_of(&app, id).and_then(|f| f.color()), Some(cmyk), "a click paints the art"); +} + #[test] fn alt_dropping_a_gradient_on_a_gradient_swatch_replaces_it() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 157b58d3cb..263342741a 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -120,6 +120,26 @@ fn clicking_a_library_brush_adds_it_to_the_documents_brushes_in_one_step() { assert!(last_open(&app.ui).is_none()); } +#[test] +fn the_ink_and_wash_library_opens_and_a_click_adds_and_applies_its_brush() { + let mut app = app(); + let ctx = context(); + let r = app.run("window.brushLibrary", json!({"library": "ink-and-wash"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("ink-and-wash"), Some(4))); + let lib = BrushLibraries::get(&app, "ink-and-wash").unwrap().1; + let rows = BrushLibraries::rows(&lib, ""); + assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"]); + let path = app.run("shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60})).unwrap()["id"].as_u64().unwrap(); + let (undo, brushes) = (undo_len(&app), count(&app)); + click(&mut app, &ctx, "Pooling Wash", Modifiers::NONE, 0.0); + assert_eq!((count(&app), undo_len(&app)), (brushes + 1, undo + 1), "one brush, one undo step"); + let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(path)).unwrap().clone(); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.clone()).as_deref(), Some("Pooling Wash")); + click(&mut app, &ctx, "Brush Pen", Modifiers::NONE, 2.0); + assert!(has(&app, "Brush Pen")); + assert_eq!(app.run("brush.list", json!({})).unwrap()["current"], "Brush Pen"); +} + #[test] fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { let mut app = app(); @@ -131,6 +151,7 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { opened, [ json!("art-ribbons"), + json!("ink-and-wash"), json!("calligraphic-nibs"), json!("bristle-brushes"), json!("pattern-borders"), diff --git a/crates/ui-egui/src/panels/tests_strokedepth.rs b/crates/ui-egui/src/panels/tests_strokedepth.rs index e37b6e75b7..1d80cc1c66 100644 --- a/crates/ui-egui/src/panels/tests_strokedepth.rs +++ b/crates/ui-egui/src/panels/tests_strokedepth.rs @@ -100,9 +100,52 @@ fn every_arrowhead_preview_draws_its_head() { }; let plain = (ink(None, false), ink(None, true)); for a in Arrowhead::ALL { - assert!(ink(Some(a), false) > plain.0 + 20, "{a:?} at the end"); - assert!(ink(Some(a), true) > plain.1 + 20, "{a:?} at the start"); + assert!(ink(Some(a.clone()), false) > plain.0 + 20, "{a:?} at the end"); + assert!(ink(Some(a.clone()), true) > plain.1 + 20, "{a:?} at the start"); } + // So does a custom head: a 4 × 4 square behind its tip. + let pts = [(0.0, -2.0), (4.0, -2.0), (4.0, 2.0), (0.0, 2.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let square = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + let custom = Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new("Square".into(), square))); + assert!(ink(Some(custom.clone()), false) > plain.0 + 20); + assert!(ink(Some(custom), true) > plain.1 + 20); +} + +#[test] +fn a_long_custom_arrowhead_preview_fits_in_its_box() { + use vectorcraft_doc::Arrowhead; + // 40 units long and 4 wide, reaching 4 units past its tip. + let pts = [(-4.0, -2.0), (36.0, -2.0), (36.0, 2.0), (-4.0, 2.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let bar = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + // 120 units across: smaller than the smallest head weight can draw it. + let pts = [(0.0, -60.0), (4.0, -60.0), (4.0, 60.0), (0.0, 60.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let wall = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + let heads = [bar, wall].map(|p| Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new("Bar".into(), p)))); + for (head, start) in heads.iter().flat_map(|h| [(h, false), (h, true)]) { + let d = stroke::arrow_doc(Some(head.clone()), start, 56.0, 22.0).unwrap(); + // Rendered well beyond the 56 × 22 pt box, at 1 pixel a point: all the ink is inside it. + let img = vectorcraft_render::Renderer::new().render_region(&d, vectorcraft_geom::Rect::new(-100.0, -50.0, 156.0, 72.0), 1.0, false); + let ink: Vec<(u32, u32)> = (0..256).flat_map(|x| (0..122).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 64).collect(); + assert!(ink.len() > 40, "start {start}: the line and head draw"); + let outside: Vec<&(u32, u32)> = ink.iter().filter(|&&(x, y)| !(100..156).contains(&x) || !(50..72).contains(&y)).collect(); + assert!(outside.is_empty(), "start {start}: {} pixels outside the box, e.g. {:?}", outside.len(), outside.first()); + } +} + +#[test] +fn the_arrowhead_menu_renders_only_the_previews_in_view() { + let ctx = egui::Context::default(); + let mut app = app_with_line(); + let t = frame_events(&ctx, &mut app, vec![], stroke::show); + let label = text_rect(&t, "Arrowheads:"); + let sp = ctx.global_style().spacing.item_spacing.x; + click(&ctx, &mut app, egui::pos2(label.right() + 2.0 * sp + 64.0 + 32.0, label.center().y), stroke::show); + frame_events(&ctx, &mut app, vec![], stroke::show); + frame_events(&ctx, &mut app, vec![], stroke::show); + let previews = ctx.tex_manager().read().allocated().filter(|(_, m)| m.name.contains(":arrow:") && m.name.ends_with(":false")).count(); + // The button's and the rows' in the 360 pt list, not one for each of the menu's heads. + let rows = vectorcraft_doc::Arrowhead::ALL.len() + 1; + assert!(previews > 5 && previews < rows / 2 + 5, "{previews} previews for {rows} rows"); } #[test] diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index e7c64463de..4e02180017 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -358,6 +358,9 @@ pub struct Entry { /// Menu path ("Object › Arrange") or "Tools". pub group: String, pub is_tool: bool, + /// Keyboard Shortcuts adds the key to the entry's name: another entry has the same group and + /// label ([`distinct_names`]). + pub shows_key: bool, } /// The editor's entries, the Effect menu's effect items and Object › Plug-ins' filter items while @@ -419,20 +422,34 @@ fn build_snapshot(revision: u64) -> Arc { let effects: Arc<[EffectItem]> = build_effect_items().into(); let filters: Arc<[FilterItem]> = build_filter_items().into(); let (head, tail) = fixed_entries(); - let entries: Arc<[Entry]> = head + let mut entries: Vec = head .iter() .cloned() - .chain(effects.iter().map(|e| Entry { key: format!("effect:{}", e.effect), label: e.label.clone(), group: e.group.clone(), is_tool: false })) - .chain(filters.iter().map(|f| Entry { key: format!("plugin:{}", f.id), label: f.label.clone(), group: f.group.clone(), is_tool: false })) + .chain(effects.iter().map(|e| Entry { + key: format!("effect:{}", e.effect), + label: e.label.clone(), + group: e.group.clone(), + is_tool: false, + shows_key: false, + })) + .chain(filters.iter().map(|f| Entry { + key: format!("plugin:{}", f.id), + label: f.label.clone(), + group: f.group.clone(), + is_tool: false, + shows_key: false, + })) .chain(vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| Entry { key: c.id.into(), label: c.label.into(), group: c.menu.join(" › "), is_tool: false, + shows_key: false, })) .chain(tail.iter().cloned()) .collect(); - Arc::new(Snapshot { revision, effects, filters, entries }) + distinct_names(&mut entries); + Arc::new(Snapshot { revision, effects, filters, entries: entries.into() }) } /// Every tool, command and panel the editor can give a shortcut; [`listed`] says which it lists. @@ -446,7 +463,7 @@ fn fixed_entries() -> &'static (Vec, Vec) { static E: OnceLock<(Vec, Vec)> = OnceLock::new(); E.get_or_init(|| { let mut v: Vec = vectorcraft_tools::catalog::all_tools() - .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true }) + .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true, shows_key: false }) .collect(); let mut seen = HashSet::new(); // The commands listed only because an item of the menu bar runs them as a key press would @@ -461,13 +478,14 @@ fn fixed_entries() -> &'static (Vec, Vec) { by_the_menu_bar.insert(c.id); } let group = if c.menu.is_empty() { id_group(c.id) } else { c.menu.join(" › ") }; - v.push(Entry { key: c.id.into(), label: c.label.into(), group, is_tool: false }); + v.push(Entry { key: c.id.into(), label: c.label.into(), group, is_tool: false, shows_key: false }); } v.extend(crate::state::all_panels().map(|(id, label)| Entry { key: format!("panel:{id}"), label: label.to_string(), group: "Window".into(), is_tool: false, + shows_key: false, })); // Each effect of the Effect menu comes next, by its own key (#3296): see [`snapshot`]. let mut tail = vec![]; @@ -479,14 +497,16 @@ fn fixed_entries() -> &'static (Vec, Vec) { if !own { by_the_menu_bar.insert(id); } - tail.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false }); + tail.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false, shows_key: false }); } // A command listed only because an item of the menu bar runs it can have the group and // label of another entry. When that entry is an engine command that no item of the menu bar // runs, the command the item runs keeps the name and the other entry is shown in the group // of its id: File › Export Selection… runs `file.exportSelection`, as the menu item does, // and `document.exportSelection` shows as Document › Export Selection…. Otherwise the - // command is left out: Window › Learn stays the Learn panel's entry. + // command is left out: Window › Learn stays the Learn panel's entry. Entries that still + // have the same name, plug-ins included, are given names of their own by + // [`distinct_names`]. let name = |e: &Entry| (e.group.clone(), e.label.clone()); let new_names: HashSet<_> = v.iter().chain(&tail).filter(|e| by_the_menu_bar.contains(e.key.as_str())).map(name).collect(); for e in v.iter_mut().chain(tail.iter_mut()) { @@ -621,9 +641,21 @@ pub(crate) fn shown_group(group: &str) -> String { group.split(" › ").map(|s| tl!(s)).collect::>().join(" › ") } -/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu. +/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu, and +/// an entry that [`Entry::shows_key`] with its key after the name. pub(crate) fn shown_name(e: &Entry) -> String { - if e.is_tool || e.group.is_empty() { shown_label(e).to_string() } else { format!("{} › {}", shown_group(&e.group), shown_label(e)) } + name_of(e, &shown_group(&e.group), shown_label(e)) +} + +/// [`shown_name`] in English, for a status or error message, which is translated when shown. +fn english_name(e: &Entry) -> String { + name_of(e, &e.group, &e.label) +} + +/// Entry `e`'s name with its group and label as `group` and `label`. +fn name_of(e: &Entry, group: &str, label: &str) -> String { + let name = if e.is_tool || e.group.is_empty() { label.to_string() } else { format!("{group} › {label}") }; + if e.shows_key { format!("{name} ({})", e.key) } else { name } } /// The group of a command no menu holds: the first part of its id, capitalised (`edit.x` → "Edit"). @@ -632,6 +664,107 @@ fn id_group(id: &str) -> String { c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() } +/// Gives entries that have the same group and label names of their own, and leaves the others as +/// they are. It runs on the whole snapshot, plug-ins included, after [`fixed_entries`] has shown an +/// engine command in the group of its id or left out a command listed only because an item of the +/// menu bar runs it: +/// 1. A command that an item of the menu bar runs, as a key press would run it, is shown under +/// that item's menu path ([`menu_bar_path`]): the four Other Library… commands go under Window › +/// Swatch Libraries, Brush Libraries, Graphic Style Libraries and Symbol Libraries. +/// 2. A command whose id is another command's id with one more part is shown under that command's +/// label: Reset Panel goes under Character Style Options and Paragraph Style Options. +/// 3. Entries that still have the same name show their key after it ([`Entry::shows_key`]), except +/// the one entry an item of the menu bar runs: File › New… stays the New Document dialog's +/// entry, and `file.new` shows as File › New… (file.new). An entry whose name is then the name +/// another entry has with its key (a plug-in named "Blur (plugin:a)") shows its key too. +fn distinct_names(entries: &mut [Entry]) { + // The entries of each name that more than one entry has. + fn shared(entries: &[Entry]) -> Vec> { + let mut by_name: HashMap> = HashMap::new(); + for (i, e) in entries.iter().enumerate() { + by_name.entry(english_name(e)).or_default().push(i); + } + by_name.into_values().filter(|list| list.len() > 1).collect() + } + for i in shared(entries).into_iter().flatten() { + let Some(e) = entries.get_mut(i).filter(|e| !e.is_tool) else { continue }; + if let Some(path) = menu_bar_path(&e.key) { + e.group = path.to_string(); + } else if let Some((parent, _)) = e.key.rsplit_once('.').and_then(|(parent, _)| command_doc(parent)) { + e.group = parent.trim_end_matches('…').to_string(); + } + } + // A pass gives every entry of a shared name but one its key, so a name is shared again only by + // an entry whose label ends with another entry's key; one pass per entry is enough. + for _ in 0..entries.len() { + let lists = shared(entries); + if lists.is_empty() { + break; + } + for list in lists { + let plain: Vec = list.iter().copied().filter(|&i| entries.get(i).is_some_and(|e| !e.shows_key)).collect(); + let on_the_menu_bar: Vec = + plain.iter().copied().filter(|&i| entries.get(i).is_some_and(|e| !e.is_tool && menu_bar_path(&e.key).is_some())).collect(); + // The entry an item of the menu bar runs keeps the plain name, unless another entry + // already shows that name with its key. + let keeps = on_the_menu_bar.first().copied().filter(|_| on_the_menu_bar.len() == 1 && plain.len() == list.len()); + for i in plain { + if let Some(e) = entries.get_mut(i) + && keeps != Some(i) + { + e.shows_key = true; + } + } + } + } +} + +/// The (label, params doc) of engine or UI command `id`. +fn command_doc(id: &str) -> Option<(&'static str, &'static str)> { + vectorcraft_engine::find_command(id).map(|c| (c.label, c.params)).or_else(|| menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| (c.1, c.3))) +} + +/// The (menu path, params) of each item of the menu bar that [`menu_bar_items`] reads, by command +/// id: "Window › Swatch Libraries" for Window › Swatch Libraries › Other Library…. +fn menu_bar_paths() -> &'static HashMap<&'static str, Vec<(String, Value)>> { + fn walk(items: &[menus::Item], path: &mut Vec, out: &mut HashMap<&'static str, Vec<(String, Value)>>) { + for it in items { + match it { + menus::Item::Cmd(label, id, p) if on_the_bar(id, label, p) => out.entry(*id).or_default().push((path.join(" › "), p.clone())), + menus::Item::Sub(name, children) => { + path.push(name.to_string()); + walk(children, path, out); + path.pop(); + } + _ => {} + } + } + } + static PATHS: OnceLock>> = OnceLock::new(); + PATHS.get_or_init(|| { + let mut out = HashMap::new(); + for (name, items) in menus::menu_tree() { + walk(&items, &mut vec![name.to_string()], &mut out); + } + out + }) +} + +/// The menu path of the item of the menu bar that runs command `id` as a key press runs it +/// ([`a_menu_bar_item_runs`]): the first item that runs it with no params, or else the first item, +/// whose params all its items give. +fn menu_bar_path(id: &str) -> Option<&'static str> { + let (_, params) = command_doc(id)?; + let items = menu_bar_paths().get(id).filter(|_| a_menu_bar_item_runs(id, params))?; + chosen_path(items) +} + +/// The menu path [`menu_bar_path`] takes from a command's (menu path, params) items: the first +/// item with no params, or else the first item. +fn chosen_path(items: &[(String, Value)]) -> Option<&str> { + items.iter().find(|(_, p)| is_bare(p)).or(items.first()).map(|(path, _)| path.as_str()) +} + /// Does a command's params doc (`{a?, b?} …`) say it runs with no params, as a shortcut runs it? fn runs_without_params(params: &str) -> bool { params @@ -688,9 +821,7 @@ fn bar_items(tree: &[(&'static str, Vec)]) -> HashMap<&'static str, fn walk(items: &[menus::Item], out: &mut HashMap<&'static str, Vec<(&'static str, Value)>>) { for it in items { match it { - menus::Item::Cmd(label, id, p) if !menus::lists_plugin(id, p) && !menus::shows_a_name(id, label) => { - out.entry(*id).or_default().push((*label, p.clone())) - } + menus::Item::Cmd(label, id, p) if on_the_bar(id, label, p) => out.entry(*id).or_default().push((*label, p.clone())), menus::Item::Sub(_, children) => walk(children, out), _ => {} } @@ -703,6 +834,12 @@ fn bar_items(tree: &[(&'static str, Vec)]) -> HashMap<&'static str, out } +/// Is the menu item with `label` that runs command `id` with params `p` one [`bar_items`] reads: it +/// lists no installed plug-in and shows no name that is user data? +fn on_the_bar(id: &str, label: &str, p: &Value) -> bool { + !menus::lists_plugin(id, p) && !menus::shows_a_name(id, label) +} + fn is_bare(p: &Value) -> bool { p.as_object().is_none_or(|o| o.is_empty()) } @@ -932,7 +1069,7 @@ pub fn assign_in(km: &mut Keymap, key: &str, chord: Option<&str>, force: bool) - .iter() .map(|h| { let k = entry_key(&h.command); - entry(&k).map_or(k, |e| e.label.clone()) + entry(&k).map_or(k, |e| english_name(&e)) }) .collect(); Err(format!("{} is already used by {}", menus::pretty_shortcut(&vc_format(&chord)), names.join(", "))) @@ -1364,12 +1501,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { Cow::Borrowed(_) => view.texts.get(i).cloned().flatten(), Cow::Owned(km) => dialog_shortcuts_with(km, &e.key, view.backtick.as_deref()), }; - let matches = e.label.to_lowercase().contains(&q) - || shown_label(e).to_lowercase().contains(&q) - || e.group.to_lowercase().contains(&q) - || shown_group(&e.group).to_lowercase().contains(&q) - || sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q)); - if !q.is_empty() && !matches { + if !q.is_empty() && !row_matches(e, &q, sc.as_deref()) { continue; } let row = ui.horizontal(|ui| { @@ -1547,6 +1679,18 @@ fn drop_hidden(d: &mut Dialog, overrides: &Overrides) { } } +/// Does the search text `q` (lowercase) find entry `e`, whose shortcut text is `sc`? It does when +/// `e`'s label or group, in English or as shown, the key it shows ([`Entry::shows_key`]) or its +/// shortcut contains `q`. +fn row_matches(e: &Entry, q: &str, sc: Option<&str>) -> bool { + e.label.to_lowercase().contains(q) + || shown_label(e).to_lowercase().contains(q) + || e.group.to_lowercase().contains(q) + || shown_group(&e.group).to_lowercase().contains(q) + || (e.shows_key && e.key.to_lowercase().contains(q)) + || sc.is_some_and(|s| s.to_lowercase().contains(q)) +} + /// Apply a recorded chord to `key` in the working set, warning about (and resolving) conflicts. fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) { d.fields.insert("__recording".into(), json!("")); @@ -1554,7 +1698,7 @@ fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) { Ok(removed) => { d.fields.insert("set".into(), json!(CUSTOM)); if let Some(first) = removed.first() { - let names: Vec = removed.iter().map(|k| entry(k).map(|e| e.label.clone()).unwrap_or(k.clone())).collect(); + let names: Vec = removed.iter().map(|k| entry(k).map(|e| shown_name(&e)).unwrap_or(k.clone())).collect(); d.fields.insert( "__message".into(), json!(crate::i18n::fmt( @@ -1888,7 +2032,7 @@ mod tests { /// An Export Text line: the row's name as the dialog shows it, a tab and `shortcut`. fn text_row(key: &str, shortcut: &str) -> String { let e = entry(key).unwrap_or_else(|| panic!("no entry {key}")); - if e.is_tool || e.group.is_empty() { format!("{}\t{shortcut}", e.label) } else { format!("{} › {}\t{shortcut}", e.group, e.label) } + format!("{}\t{shortcut}", english_name(&e)) } /// Export Text writes the set's name, then the Tools and the Menu Commands rows in the dialog's @@ -1901,11 +2045,7 @@ mod tests { let text = export_text("VectorCraft Defaults", &ov); let km = keymap_with(&ov); let names = |tools: bool| -> Vec { - entries() - .iter() - .filter(|e| e.is_tool == tools && listed(km.overrides(), e)) - .map(|e| if e.is_tool || e.group.is_empty() { format!("{}\t", e.label) } else { format!("{} › {}\t", e.group, e.label) }) - .collect() + entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)).map(|e| format!("{}\t", english_name(e))).collect() }; let rows = (names(true), names(false)); (text, km, rows.0, rows.1) @@ -2126,7 +2266,7 @@ mod tests { return; } for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { - let label = entry(edit).map(|e| e.label.clone()).unwrap(); + let label = entry(edit).map(|e| english_name(&e)).unwrap(); assert_eq!(conflicts_with(&Overrides::new(), alias, "tool:pen"), vec![edit.to_string()], "{alias}"); let mut ov = Overrides::new(); let err = assign(&mut ov, "tool:pen", Some(alias), false).unwrap_err(); @@ -2484,6 +2624,111 @@ mod tests { } } + /// No two entries of Keyboard Shortcuts have the same English name. A command that an item of + /// the menu bar runs takes that item's menu path (Window › Swatch Libraries › Other Library…), a + /// command whose id extends another command's id takes that command's label (Character Style + /// Options › Reset Panel), and the other entries that would share a name show their key, except + /// the one an item of the menu bar runs: File › New… is the New Document dialog's entry, and + /// `file.new` is File › New… (file.new). An entry whose name no other entry has keeps its group. + /// The search finds an entry by the key it shows, and a conflict, in `shortcuts.set` and in the + /// dialog, names the entry that has the shortcut by its whole name. + #[test] + fn no_two_entries_have_the_same_name() { + let all = entries(); + let mut by_name: HashMap> = HashMap::new(); + for e in all.iter() { + by_name.entry(english_name(e)).or_default().push(e.key.as_str()); + } + let shared: Vec<_> = by_name.into_iter().filter(|(_, keys)| keys.len() > 1).collect(); + assert!(shared.is_empty(), "{shared:?}"); + let shown = |id: &str| entry(id).map(|e| (e.group, e.label, e.shows_key)); + for (id, group) in [ + ("window.brushLibrary.other", "Window › Brush Libraries"), + ("window.graphicStyleLibrary.other", "Window › Graphic Style Libraries"), + ("window.swatchLibrary.other", "Window › Swatch Libraries"), + ("window.symbolLibrary.other", "Window › Symbol Libraries"), + ] { + assert_eq!(shown(id), Some((group.into(), "Other Library…".into(), false)), "{id}"); + } + assert_eq!(shown("ui.charStyleOptions.resetPanel"), Some(("Character Style Options".into(), "Reset Panel".into(), false))); + assert_eq!(shown("ui.paraStyleOptions.resetPanel"), Some(("Paragraph Style Options".into(), "Reset Panel".into(), false))); + assert_eq!(shown("file.newDialog"), Some(("File".into(), "New…".into(), false))); + assert_eq!(shown("file.new"), Some(("File".into(), "New…".into(), true))); + assert_eq!(shown("text.fitHeadline"), Some(("Type".into(), "Fit Headline".into(), false))); + assert_eq!(shown("type.fitHeadline"), Some(("Type".into(), "Fit Headline".into(), true))); + for id in ["file.exportInBackground", "file.openInBackground"] { + assert_eq!(shown(id), Some(("File".into(), "Continue in Background".into(), true)), "{id}"); + } + assert_eq!(shown("file.cancelExport"), Some(("File".into(), "Cancel Export".into(), false))); + let e = entry("type.fitHeadline").unwrap(); + assert!(shown_name(&e).ends_with("Fit Headline (type.fitHeadline)"), "{}", shown_name(&e)); + assert!(row_matches(&e, "type.fitheadline", None)); + let e = entry("text.fitHeadline").unwrap(); + assert!(shown_name(&e).ends_with("Fit Headline"), "{}", shown_name(&e)); + assert!(!row_matches(&e, "text.fitheadline", None)); + let mut ov = Overrides::new(); + assign(&mut ov, "window.swatchLibrary.other", Some("Cmd+Alt+Shift+F11"), false).unwrap(); + let err = assign(&mut ov, "window.brushLibrary.other", Some("Cmd+Alt+Shift+F11"), false).unwrap_err(); + assert!(err.ends_with("is already used by Window › Swatch Libraries › Other Library…"), "{err}"); + assign(&mut ov, "type.fitHeadline", Some("Cmd+Alt+Shift+F10"), false).unwrap(); + let err = assign(&mut ov, "text.fitHeadline", Some("Cmd+Alt+Shift+F10"), false).unwrap_err(); + assert!(err.ends_with("is already used by Type › Fit Headline (type.fitHeadline)"), "{err}"); + let mut d = Dialog::new("shortcuts", json!({})); + let mut km = keymap_with(&Overrides::new()); + record(&mut d, &mut km, "type.fitHeadline", "Cmd+Alt+Shift+F10"); + record(&mut d, &mut km, "text.fitHeadline", "Cmd+Alt+Shift+F10"); + assert!(d.str("__message").contains("was already used by Type › Fit Headline (type.fitHeadline) "), "{}", d.str("__message")); + } + + /// Two filter plug-ins with the same name in the same menu show their ids, and a third whose + /// name is one of those names with the id stays apart from it. A filter with that name in + /// another menu, or with a label that differs by its "…", shows no id. + #[test] + fn a_plugin_named_like_another_row_with_its_key_stays_apart() { + let row = |key: &str, label: &str, group: &str| Entry { + key: key.into(), + label: label.into(), + group: group.into(), + is_tool: false, + shows_key: false, + }; + let menu = "Object › Plug-ins"; + let mut rows = vec![ + row("plugin:a", "Foo", menu), + row("plugin:b", "Foo", menu), + row("plugin:c", "Foo (plugin:a)", menu), + row("plugin:d", "Bar", menu), + row("plugin:e", "Foo", "Object › Plug-ins › Other"), + row("plugin:f", "Foo…", menu), + ]; + distinct_names(&mut rows); + let names: Vec = rows.iter().map(english_name).collect(); + assert_eq!( + names, + [ + "Object › Plug-ins › Foo (plugin:a)", + "Object › Plug-ins › Foo (plugin:b)", + "Object › Plug-ins › Foo (plugin:a) (plugin:c)", + "Object › Plug-ins › Bar", + "Object › Plug-ins › Other › Foo", + "Object › Plug-ins › Foo…" + ] + ); + } + + /// A command with items in more than one menu is shown under the path of its first item with + /// no params, or else of its first item: Recolor Artwork… under Edit › Edit Colors, not Edit › + /// Edit Colors › Recolor with Preset. + #[test] + fn a_command_on_several_items_takes_the_path_of_its_item_with_no_params() { + let item = |path: &str, p: Value| (path.to_string(), p); + let items = [item("A › B", json!({"x": 1})), item("A › C", Value::Null), item("A › D", json!({}))]; + assert_eq!(chosen_path(&items), Some("A › C")); + assert_eq!(chosen_path(&[item("A › B", json!({"x": 1})), item("A › C", json!({"x": 2}))]), Some("A › B")); + assert_eq!(chosen_path(&[]), None); + assert_eq!(menu_bar_path("ui.recolorDialog"), Some("Edit › Edit Colors")); + } + /// `shortcuts.set`, `shortcuts.list` and `shortcuts.preset` reject a supplied param of the wrong /// type with an error naming it, and change nothing; an omitted or null param keeps its default. #[test] diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f0ff34ef64..db262870ab 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -787,8 +787,20 @@ fn busy_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo) // The arrows while dragging: a polygon's sides, a grid's rows (↑/↓) and columns (←/→)… let arrows = [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)]; for (k, tk) in arrows { + // The press's Shift and Alt (the consume below takes those presses too) go to a tool that + // claims the arrow (Shift steps a blend widget by ten); other tools get the plain arrow. + let pressed = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if *key == k && !(modifiers.command || modifiers.ctrl) => Some(*modifiers), + _ => None, + }) + }); if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { - let _ = app.session.tool_key(tk, Mods::default(), view); + let mods = match pressed { + Some(m) if app.session.tool_claims_key(tk, view) => crate::canvas::mods(m, false), + _ => Mods::default(), + }; + let _ = app.session.tool_key(tk, mods, view); } } // Digits (5 while dragging with the Perspective Selection tool), once per press. diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index e002d07adf..60b8c5eacd 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -2982,8 +2982,13 @@ fn a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item() { fn a_filter_plugin_row_translates_its_path_and_not_its_name() { let zh = crate::i18n::Lang::from_code("zh-hant").unwrap(); crate::i18n::set_current(zh); - let entry = - |key: &str| crate::shortcut_editor::Entry { key: key.into(), label: "Group".into(), group: "Object › Plug-ins".into(), is_tool: false }; + let entry = |key: &str| crate::shortcut_editor::Entry { + key: key.into(), + label: "Group".into(), + group: "Object › Plug-ins".into(), + is_tool: false, + shows_key: false, + }; let path = format!("{} › {}", crate::i18n::tr(zh, "Object"), crate::i18n::tr(zh, "Plug-ins")); let (filter, command) = (crate::shortcut_editor::shown_name(&entry("plugin:a.b")), crate::shortcut_editor::shown_name(&entry("object.group"))); crate::i18n::set_current(crate::i18n::Lang::EN); @@ -3032,6 +3037,38 @@ fn a_plain_up_nudges_unless_a_corner_widget_is_pressed() { assert_eq!(kind2, CornerKind::InvertedRound, "Up stepped the kind"); } +/// While a blend widget is pressed, an arrow steps its value by one and Shift with an arrow by ten. +#[test] +fn shift_arrows_step_a_pressed_blend_widget_by_ten() { + use vectorcraft_doc::{NodeId, NodeKind}; + use vectorcraft_tools::blendwidget::BlendWidget; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let rect = |app: &mut VectorcraftApp, x: f64| { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 190, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (rect(&mut app, 90.0), rect(&mut app, 290.0)); + app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let id = NodeId(app.session.execute("object.blend.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let shift = |app: &VectorcraftApp| { + let NodeKind::Blend { spec, .. } = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("not a blend") }; + spec.color_shift + }; + let view = app.view_info(); + let st = app.session.active().unwrap(); + let at = BlendWidget::of(&st.doc, &st.selection, view.zoom, vectorcraft_geom::Vec2::new(0.0, -1.0))[0].at; + app.session.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), view).unwrap(); + frame(&mut app, press(Key::ArrowUp, Modifiers::SHIFT)); + assert_eq!(shift(&app), 10.0, "Shift+Up steps by ten"); + frame(&mut app, press(Key::ArrowUp, Modifiers::NONE)); + assert_eq!(shift(&app), 11.0, "Up steps by one"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, at.x, at.y), view).unwrap(); + assert_eq!(shift(&app), 11.0); +} + /// #5218: the status bar navigator's four buttons are commands of their own, listed in Edit › /// Keyboard Shortcuts without a default key, so a user can give them one (Page Up and Page Down /// here) and step through the artboards from the keyboard. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ec026573d0..f55db4f5e7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -420,6 +420,10 @@ pub struct UiState { /// The Learn panel's tab, skill and tutorial, kept while the app runs. #[serde(skip)] pub learn: crate::panels::learn::View, + /// The tool whose place a Learn tutorial step's link shows (`learn.locateTool`), while its mark + /// is on screen. Not saved. + #[serde(skip)] + pub learn_locate: Option, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -605,6 +609,16 @@ pub struct UiState { /// adds a new action, as after Begin Recording. #[serde(skip)] pub recording_into: Option, + /// The length of the action set file that Stop Playing/Recording would leave without the steps + /// recorded so far, once measured; None until then, and again after an app command, which may + /// change the sets. + #[serde(skip)] + pub recording_base: Option, + /// The bytes that the steps recorded so far add to that file, at most, as last counted: (the + /// journal's rewrites, the recording's first entry, the journal's length, the bytes) + /// ([`vectorcraft_engine::Journal::rewrites`]). + #[serde(skip)] + pub recording_tail: Option<(u64, usize, usize, usize)>, /// How actions play (Actions panel menu › Playback Options…), kept with the UI preferences. #[serde(default, deserialize_with = "crate::panels::actions::deserialize_playback")] pub action_playback: crate::panels::actions::PlaybackOptions, @@ -1051,6 +1065,7 @@ impl Default for UiState { color_settings_scroll: false, find_font_in: String::new(), learn: Default::default(), + learn_locate: None, control_bar: false, control_bar_bottom: false, control_bar_hidden_groups: Default::default(), @@ -1109,6 +1124,8 @@ impl Default for UiState { action_sets_selected: vec![], recording: None, recording_into: None, + recording_base: None, + recording_tail: None, action_playback: Default::default(), action_playing: None, shortcut_overrides: Default::default(), diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 3a84e0b94d..43072d4a6e 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -128,6 +128,45 @@ fn control_saves_leave_out_the_documents_file_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// In one session with a library loaded from outside the read root: the library keeps its file-name +/// id, a reply that names its path shows the file name, a save without a path leaves out the +/// document's file, and a save or export to a path the client gave in the write root keeps that path +/// in `path` and `files`, also in a `command.batch` step's result. A refused path outside the write root is still shown by its file name. +#[test] +fn control_saves_keep_the_given_path_beside_a_hidden_library() { + let (mut app, tx, base) = confined("save-and-library"); + let library = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{library}"); + app.run("file.new", json!({"width": 100, "height": 50})).unwrap(); + let own = s(base.join("out/doc.vectorcraft")); + app.run("file.saveAs", json!({"path": own})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let r = request(&mut app, &tx, "app.save", json!({})); + assert!(r["result"].get("path").is_none() && !r.to_string().contains(&s(base.join("out"))), "{r}"); + let r = request(&mut app, &tx, "app.save", json!({"path": own})); + assert_eq!(r["result"]["path"], json!(own), "{r}"); + let split = s(base.join("out/split.vectorcraft")); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.save", "params": {"path": split, "separateArtboards": true}})); + assert_eq!(r["result"]["path"], json!(split), "{r}"); + assert_eq!(r["result"]["files"][0], json!(split), "{r}"); + let batched = s(base.join("out/batched.vectorcraft")); + let steps = json!({"commands": [{"command": "document.save", "params": {"path": batched}}]}); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "command.batch", "params": steps})); + assert_eq!(r["result"]["results"][0]["path"], json!(batched), "{r}"); + let svg = s(base.join("out/a.svg")); + let r = request(&mut app, &tx, "app.export", json!({"path": svg, "wait": true})); + assert_eq!(r["result"]["path"], json!(svg), "{r}"); + app.ui.status = format!("from {library}"); + let r = request(&mut app, &tx, "test.status", json!({})); + assert_eq!(r["result"], "from b.svg", "{r}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + let refused = s(base.join("outside/c.vectorcraft")); + let r = request(&mut app, &tx, "app.save", json!({"path": refused, "wait": true})); + assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the write root") && !e.contains(&refused)), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true` /// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it /// before they reply. Without `wait` the refusal reaches only the status bar. @@ -446,6 +485,366 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before +/// it is a relative path, outside the root, and is left out. +#[test] +fn ui_inspect_checks_a_folder_preference_as_written() { + let (mut app, tx, base) = confined("inspect-folder-space"); + let inside = s(base.join("in")); + app.ui.engine_prefs = json!({"fontsFolder": inside, "templatesFolder": format!(" {inside}")}); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); + assert_eq!(prefs.get("fontsFolder").and_then(Value::as_str), Some(inside.as_str()), "{r}"); + assert!(!prefs.contains_key("templatesFolder"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` checks a folder preference as the app uses it, trimmed, as well: with the working +/// folder as the read root, a path outside it with a space before it reads as a name in the working +/// folder, and is still left out, because the app trims it and uses the path outside. +#[test] +fn ui_inspect_checks_a_folder_preference_as_trimmed() { + let (_, _, base) = confined("inspect-folder-trimmed"); + let cwd = std::env::current_dir().unwrap(); + let roots = AutomationRoots::new(&[&cwd], &[&base.join("out")]).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx).with_automation_roots(roots); + let outside = s(base.join("outside")); + // Without its drive, the path starts with a separator: trimmed, it is absolute on any platform. + let rooted = match outside.as_bytes() { + [drive, b':', ..] if drive.is_ascii_alphabetic() => &outside[2..], + _ => outside.as_str(), + }; + let spaced = format!(" {rooted}"); + app.ui.engine_prefs = json!({"fontsFolder": spaced, "pluginsFolder": ""}); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); + assert_eq!(prefs.get("pluginsFolder").and_then(Value::as_str), Some(""), "{r}"); + assert!(!prefs.contains_key("fontsFolder"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a hidden library's id and path in every string it holds: status text, the +/// command palette query and a script's report show the id and file name shown, and the path appears +/// nowhere in the reply. +#[test] +fn ui_inspect_hides_a_hidden_library_path_in_every_string() { + let (mut app, tx, base) = confined("inspect-status-id"); + let path = s(base.join("outside/b.svg")); + let theirs = format!("loaded/{path}"); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = format!("Loaded {path} (3 swatches), kept {path}.bak"); + app.ui.palette_query = format!("\"{theirs}\""); + let source = format!("vc.log({})", json!(format!("read {path}."))); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + assert_eq!(r["result"]["ui"]["status"], "Loaded b.svg (3 swatches), kept b.svg.bak", "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "\"loaded/b.svg\"", "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["read b.svg."]), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// The reply to a request that names a hidden library by the id `ui.inspect` shows hides that +/// library's id wherever the reply holds it, also between spaces and brackets and where a character +/// that continues a path follows it. +#[test] +fn a_reply_hides_a_library_the_request_named_by_its_shown_id_everywhere() { + let (mut app, tx, base) = confined("named-hidden-reply"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = format!("Not added ( {theirs} ); kept {theirs}_old"); + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/b.svg"})); + assert_eq!(r["result"]["ui"]["status"], "Not added ( loaded/b.svg ); kept loaded/b.svg_old", "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error to a request that names a hidden library by the id `ui.inspect` shows names it by that id, +/// and its path appears nowhere in the reply. +#[test] +fn an_error_for_a_hidden_library_named_by_its_shown_id_shows_that_id() { + let (mut app, tx, base) = confined("named-hidden-error"); + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside/b.svg"))); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.colorGuideLimit", "params": {"library": "loaded/b.svg"}})); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("loaded/b.svg")), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path outside the read root under any path +/// param (`diffPath`, `file`, a name ending in `Files`), and an absolute path or a `file:` URL outside +/// it (the scheme in any case) under any other param or in any list. A `file:` URL inside it stays. +#[test] +fn ui_inspect_leaves_out_recorded_paths_under_any_key() { + let (mut app, tx, base) = confined("inspect-action-keys"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let to_url = |p: &str| if p.starts_with('/') { format!("file://{p}") } else { format!("file:///{}", p.replace('\\', "/")) }; + let (url, url_in) = (to_url(&b), to_url(&a)); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["document.compare", {"diffPath": b, "target": a}], + ["file.open", {"file": b}], + ["type.fonts", {"fontFiles": [a, b, url_in]}], + ["x.y", {"target": b, "url": url, "upper": url.replacen("file:", "FILE:", 1), "other": "keep"}], + ["x.z", {"list": [b, "keep"]}], + ["x.w", {"share": "file://other-host/share/b.svg", "other": "keep"}], + ["x.v", {"file": url_in}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["document.compare", {"target": a}], + ["file.open", {}], + ["type.fonts", {"fontFiles": [a, url_in]}], + ["x.y", {"other": "keep"}], + ["x.z", {"list": ["keep"]}], + ["x.w", {"other": "keep"}], + ["x.v", {"file": url_in}], + ]); + assert_eq!(steps, &want, "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action +/// (its `library` and `panel` params) and in the action's name, also when nothing else names it. +#[test] +fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-action-steps"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": theirs, "steps": [ + ["swatch.library.add", {"library": theirs}], + ["window.panel", {"panel": format!("library:swatches:{theirs}")}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; + assert_eq!(action["name"], "loaded/b.svg", "{r}"); + assert_eq!( + action["steps"], + json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), + "{r}" + ); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps the file paths (`path`, `folder`, a name ending +/// in `Folder`, `paths`) outside the read root, also in a batch step's commands, and keeps the ones +/// inside it. +#[test] +fn ui_inspect_leaves_out_recorded_paths_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-action-paths"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["file.open", {"path": a}], + ["file.open", {"path": b}], + ["file.place", {"paths": [a, b]}], + ["batch.run", {"folder": s(base.join("outside"))}], + ["action.batch", {"destinationFolder": s(base.join("outside"))}], + ["command.batch", {"commands": [{"command": "file.open", "params": {"path": b}}]}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["file.open", {"path": a}], + ["file.open", {}], + ["file.place", {"paths": [a]}], + ["batch.run", {}], + ["action.batch", {}], + ["command.batch", {"commands": [{"command": "file.open", "params": {}}]}], + ]); + assert_eq!(steps, &want, "{r}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 6, "the UI state is not changed"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root whose id holds a path written with +/// either separator, also one saved on another platform. +#[test] +fn ui_inspect_hides_a_library_path_with_either_separator() { + let (mut app, tx, base) = confined("inspect-separators"); + app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + app.ui.color_guide_limit = "loaded//home/someone/c.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/c.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library does not take the id of a library loaded without a path under the same file +/// name: it shows as `loaded/ (2)`, and a request that names the other library reaches it. +#[test] +fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { + let (mut app, tx, base) = confined("inspect-loaded-id"); + let svg = std::fs::read_to_string(base.join("in/a.svg")).unwrap(); + let loaded = app.session.execute("swatch.library.load", &json!({"data": svg, "name": "b.svg"})).unwrap(); + assert_eq!(loaded["library"], "loaded/b.svg"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// Hidden libraries with the same file name that are shown for the first time in the same reply are +/// numbered in the order of their paths, as the library commands number them. +#[test] +fn hidden_libraries_are_numbered_in_the_order_of_their_paths() { + let (mut app, tx, base) = confined("hidden-load-order"); + let gpl = "GIMP Palette\nName: p\n0 0 0 Black\n"; + for dir in ["z", "a"] { + std::fs::create_dir_all(base.join("outside").join(dir)).unwrap(); + let path = base.join("outside").join(dir).join("b.gpl"); + std::fs::write(&path, gpl).unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path)})).unwrap(); + } + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("z").join("b.gpl"))); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library keeps the id a reply showed for it when another library with the same file name is +/// loaded later. +#[test] +fn a_hidden_library_keeps_its_id_when_another_with_its_name_loads() { + let (mut app, tx, base) = confined("hidden-stable-id"); + let first = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + std::fs::create_dir_all(base.join("outside").join("z")).unwrap(); + let path = base.join("outside").join("z").join("b.gpl"); + std::fs::write(&path, "GIMP Palette\nName: p\n0 0 0 Black\n").unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + app.ui.color_guide_limit = format!("loaded/{}", s(path)); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error from a panic hides a hidden library's path, also when the request stopped referring to +/// the library before it panicked. +#[test] +fn a_panic_reply_hides_a_library_hidden_before_the_request() { + let (mut app, tx, base) = confined("panic-hidden"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let r = request(&mut app, &tx, "test.panic", json!({"forget": true})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error shows a path outside the read root that the request sent by its file name, whether or not +/// a hidden library was loaded from that path, so that it reads the same either way. A path the +/// request sends does not show the app's own text that holds it, in an error or a result, and a result +/// shows that path by its file name as an error does. +#[test] +fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { + let (mut app, tx, base) = confined("client-echo"); + let path = s(base.join("outside/b.svg")); + // Another hidden library with the same file name comes first, so this one shows as `b.svg (2)`. + let first = format!("loaded/{}", s(base.join("outside/a/b.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let unloaded = [(s(base.join("outside/y/b.svg")), "b.svg"), (s(base.join("outside/c.svg")), "c.svg")]; + for (sent, name) in [(path.clone(), "b.svg")].into_iter().chain(unloaded) { + let r = request(&mut app, &tx, "app.open", json!({"path": sent})); + assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}"); + } + let r = request(&mut app, &tx, "test.status", json!({"guess": path})); + assert_eq!(r["result"], "Loaded b.svg", "{r}"); + let r = request(&mut app, &tx, "test.panic", json!({"guess": path})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A screenshot's reply, sent after its request was handled, hides a hidden library's path, and in an +/// error the path outside the read root that the request sent. +#[test] +fn a_screenshot_reply_hides_paths_like_any_reply() { + let (mut app, _tx, base) = confined("deferred-reply"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + let sent = s(base.join("outside/shot.png")); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); + let mut done = json!({"ok": true, "result": {"note": format!("from {path}")}}); + let mut failed = json!({"ok": false, "error": format!("could not write {sent}")}); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + crate::control::hide_in_deferred_reply(&app, &mut done, &json!({"path": sent})); + crate::control::hide_in_deferred_reply(&app, &mut failed, &json!({"path": sent})); + }); + assert_eq!(done["result"]["note"], "from b.svg", "{done}"); + assert_eq!(failed["error"], "could not write shot.png", "{failed}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped +/// shows the file name alone. +#[test] +fn a_reply_hides_a_path_with_escaped_separators() { + let (mut app, tx, base) = confined("escaped-separators"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("from file:{}", path.replace('/', "%2F").replace('\\', "%5C")); + let r = request(&mut app, &tx, "test.status", json!({})); + assert_eq!(r["result"], "from b.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path that starts with `./` or `../`, `~` and +/// a path under `~` outside the read root under any param, as a key too, and in any list. A URL and +/// other text with a separator stay. +#[test] +fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-relative"); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["x.y", {"target": "~/b.svg", "rel": "../outside/b.svg", "home": "~", "text": "a/b", "url": "https://example.com/a"}], + ["x.z", {"list": [".\\b.svg", "a/b"], "../k": 1}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([["x.y", {"text": "a/b", "url": "https://example.com/a"}], ["x.z", {"list": ["a/b"]}]]); + assert_eq!(steps, &want, "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// Two libraries loaded from outside the read root under the same file name, only the second of them /// in a library panel: `ui.inspect` and `swatch.library.list` show the second under the same id, and /// that id finds it. @@ -473,6 +872,43 @@ fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { let _ = std::fs::remove_dir_all(base); } +/// A library panel command given a hidden library's shown id opens the library and answers with +/// that id, not the library's path. +#[test] +fn a_library_panel_command_answers_with_a_hidden_librarys_shown_id() { + let (mut app, tx, base) = confined("panel-shown-id"); + let path = base.join("outside/x/Kit.gpl"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "GIMP Palette\nName: Kit\n0 0 0 Black\n").unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.swatchLibrary", "params": {"library": "loaded/Kit.gpl"}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(r.to_string().contains("loaded/Kit.gpl") && !r.to_string().contains("outside"), "{r}"); + assert_eq!(app.ui.library_panels.last().map(|p| p.id.clone()), Some(format!("loaded/{}", s(path))), "the app's own id"); + // The panel id it answers with names the panel in the panel commands. + let panel = r["result"]["panel"].clone(); + assert_eq!(panel, "library:swatches:loaded/Kit.gpl", "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); + assert_eq!((&r["ok"], &r["result"]["panel"]), (&json!(true), &panel), "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "swatches", "anchor": panel, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.panel.close", "params": {"panel": panel}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + // Closed, the panel isn't found, and the error names it by the id given. + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -611,6 +1047,103 @@ fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable +/// library's id that begins with it stays, and a script's report line and a command palette query +/// that end with it show the id shown. +#[test] +fn ui_inspect_rewrites_only_whole_hidden_library_ids() { + let (mut app, tx, base) = confined("inspect-whole-id"); + let hidden = format!("loaded/{}", s(base.join("i"))); + let readable = format!("loaded/{}", s(base.join("in/a.svg"))); + assert!(readable.starts_with(&hidden)); + let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; + app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; + app.ui.palette_query = format!("library:swatches:{hidden}"); + let text = format!("found {hidden}"); + let source = format!("vc.log({})", json!(text)); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["found loaded/i"]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "library:swatches:loaded/i", "{r}"); + // A request that names the hidden library by the id shown keeps the readable id too. + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/i"})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A client can name a hidden library by the id `ui.inspect` shows: `ui.set {panel}` with that panel id +/// reopens the closed panel of the library, and the reply does not show its path. +#[test] +fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { + let (mut app, tx, base) = confined("set-hidden-panel"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let real = format!("library:swatches:{theirs}"); + app.ui.docking_hidden.insert(real.clone(), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "library:swatches:loaded/b.svg"; + assert!(r["result"]["ui"]["docking_hidden"].as_object().unwrap().contains_key(shown), "{r}"); + let r = request(&mut app, &tx, "ui.set", json!({"panel": shown})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!app.ui.docking_hidden.contains_key(&real), "the closed panel is reopened: {r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // A docking command takes the shown id as its destination too. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "layers", "anchor": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // So does `ui.dock`'s move, whose destination is `target`. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "ui.dock", "params": {"operation": "move", "panel": "properties", "target": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // An error that quotes the panel shows the id the client gave, and other text is not rewritten. + let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let r = request(&mut app, &tx, "ui.set", json!({"status": "loaded/b.svg"})); + assert_eq!((r["ok"].as_bool(), app.ui.status.as_str()), (Some(true), "loaded/b.svg"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A shortcut for showing the panel of a library outside the read root, saved and in an open Keyboard +/// Shortcuts dialog, is keyed by the panel id shown, also when nothing else refers to the library. +#[test] +fn ui_inspect_hides_a_library_panels_path_in_shortcut_overrides() { + let (mut app, tx, base) = confined("shortcut-hidden-panel"); + let real = format!("panel:library:swatches:loaded/{}", s(base.join("outside/b.svg"))); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, &real, "Cmd+Alt+Shift+F7"); + app.ui.dialog = Some(crate::state::Dialog::new("shortcuts", json!({"overrides": { real.clone(): "Cmd+Alt+Shift+F8" }}))); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "panel:library:swatches:loaded/b.svg"; + let ui = &r["result"]["ui"]; + assert_eq!(ui["shortcut_overrides"][shown], "Cmd+Alt+Shift+F7", "{r}"); + assert_eq!(ui["dialog"]["fields"]["overrides"][shown], "Cmd+Alt+Shift+F8", "{r}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index ea79c1d5cc..109122308f 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -959,3 +959,142 @@ fn a_preview_stays_put_while_the_menu_is_still() { assert_eq!(picked, Some(FontPick::EndPreview)); assert_eq!(font(&app), "Inter"); } + +/// What an open font menu drew: each text with its rectangle and clip rectangle, the rectangles of +/// the samples, and the menu's rectangle. +struct Drawn { + texts: Vec<(String, egui::Rect, egui::Rect)>, + samples: Vec, + menu: egui::Rect, +} + +impl Drawn { + fn text(&self, label: &str) -> egui::Rect { + self.texts.iter().find(|(t, ..)| t == label).map(|(_, r, _)| *r).unwrap_or_else(|| panic!("{label} is drawn")) + } + + /// The filters' rows, each by the centers of its first and last chips, in the UI's language. + fn filter_rows(&self) -> Vec<(f32, f32)> { + use crate::i18n::{current, t, tr_ctx}; + let variable = tr_ctx(current(), "font-technology", "Variable"); + [(t("All Scripts"), t("Thai")), ("★", t("Rounded")), (t("Script / Brush"), t("Decorative")), (t("Font Technology"), variable)] + .map(|(first, last)| (self.text(first).center().y, self.text(last).center().y)) + .to_vec() + } + + /// The filters lie on their four rows, none wrapped. + fn assert_filter_rows(&self) { + let rows = self.filter_rows(); + for (i, (first, last)) in rows.iter().enumerate() { + assert!((first - last).abs() < 1.0, "filter row {i} wraps: its first chip at {first}, its last at {last}"); + } + assert!(rows.windows(2).all(|w| w[1].0 > w[0].0 + 4.0), "four rows of filters, in order: {rows:?}"); + } +} + +/// Opens the font menu of a field `width` wide whose left edge is `left` points into a window of +/// size `screen`, draws it until it settles and its samples are rendered, and returns what the +/// last frame drew. +fn open_menu(id: &str, left: f32, width: f32, screen: egui::Vec2) -> Drawn { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let field = egui::Rect::from_min_size(egui::pos2(left, 10.0), egui::vec2(width, 30.0)); + let frame = |events: Vec| { + let input = egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, screen)), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + ui.scope_builder(egui::UiBuilder::new().max_rect(field), |ui| { + font_menu(ui, id, "Inter", width, None, MenuLook::default()); + }); + }); + out.textures_delta.clear(); + out.shapes + }; + let click = field.min + egui::vec2(10.0, 10.0); + let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(vec![]); + frame(vec![egui::Event::PointerMoved(click), button(true)]); + frame(vec![button(false)]); + for _ in 0..5 { + frame(vec![]); + } + crate::font_menu::wait_for_samples(&ctx); + let shapes = frame(vec![]); + let mut texts = vec![]; + let mut samples = vec![]; + for s in &shapes { + match &s.shape { + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()), s.clip_rect)), + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => samples.push(m.calc_bounds()), + _ => {} + } + } + let mut drawn = Drawn { texts, samples, menu: egui::Rect::NOTHING }; + let filter = drawn.text(crate::i18n::t("Any Kind")).center(); + drawn.menu = ctx.layer_id_at(filter).and_then(|l| ctx.memory(|m| m.area_rect(l.id))).expect("the menu is open"); + drawn +} + +/// A narrow field, such as the Control bar's, opens a menu wide enough for the filters on their +/// rows and for the families' names, each drawn whole beside its sample. +#[test] +fn a_narrow_field_opens_a_menu_wide_enough_for_the_filters_and_names() { + let field = 115.0; + let d = open_menu("test-narrow", 20.0, field, egui::vec2(1200.0, 800.0)); + d.assert_filter_rows(); + assert!(d.menu.width() > 2.0 * field, "the menu is {} wide", d.menu.width()); + let families = FontDb::global().menu_family_list(); + // The field shows the current family too: only the names in the menu's rows. + let names: Vec<_> = d.texts.iter().filter(|(t, r, _)| r.top() > d.text("Variable").bottom() && families.iter().any(|f| f == t)).collect(); + assert!(names.iter().any(|(_, r, _)| r.width() > field / 2.0), "a name wider than half the field is listed: {names:?}"); + // The menu widens itself for names up to its limit; a longer name is cut. + for (name, r, clip) in names.iter().filter(|(_, r, _)| r.width() <= crate::font_menu::NAME_MAX) { + assert!(r.right() <= clip.right() + 0.5, "{name} ends at {} and is cut at {}", r.right(), clip.right()); + } +} + +/// In every UI language the filters keep their four rows unwrapped when the window has room for +/// them: the longest translated labels set the menu's width. +#[test] +fn the_filter_rows_stay_whole_in_every_language() { + for lang in crate::i18n::Lang::all() { + crate::i18n::set_current(lang); + let d = open_menu(&format!("test-lang-{}", lang.code()), 20.0, 115.0, egui::vec2(2400.0, 900.0)); + let rows = d.filter_rows(); + for (i, (first, last)) in rows.iter().enumerate() { + assert!((first - last).abs() < 1.0, "{}: filter row {i} wraps in a menu {} wide", lang.code(), d.menu.width()); + } + } + crate::i18n::set_current(crate::i18n::Lang::EN); +} + +/// A menu opened near the window's right edge is wider than its field and stays in the window. +#[test] +fn a_menu_wider_than_its_field_stays_in_the_window() { + let screen = egui::vec2(700.0, 800.0); + let d = open_menu("test-edge", 600.0, 90.0, screen); + d.assert_filter_rows(); + assert!(d.menu.width() > 200.0, "the menu is {} wide", d.menu.width()); + assert!(d.menu.left() >= -0.5 && d.menu.right() <= screen.x + 0.5, "the menu at {:?} leaves the window", d.menu); +} + +/// A docked panel's field is as wide as the panel: its menu takes that width, with the filters on +/// the same rows as a narrow field's menu. +#[test] +fn a_wide_fields_menu_takes_its_width_with_the_same_filter_rows() { + let field = 480.0; + let d = open_menu("test-docked", 20.0, field, egui::vec2(1200.0, 800.0)); + d.assert_filter_rows(); + // The field is drawn 4 points narrower than its width; the menu's frame adds its margins. + assert!(d.menu.width() >= field - 4.0 && d.menu.width() < field + 40.0, "the menu is {} wide", d.menu.width()); +} + +/// Each sample takes its row's whole height. +#[test] +fn samples_fill_their_rows_height() { + let d = open_menu("test-samples", 20.0, 260.0, egui::vec2(1200.0, 800.0)); + assert!(!d.samples.is_empty(), "samples are drawn"); + let row = MenuLook::default().row; + for s in &d.samples { + assert!((s.height() - row).abs() < 0.5, "a sample {} high in a row {row} high", s.height()); + } +} diff --git a/crates/ui-egui/src/tests/save.rs b/crates/ui-egui/src/tests/save.rs index b5e534eecf..458511043e 100644 --- a/crates/ui-egui/src/tests/save.rs +++ b/crates/ui-egui/src/tests/save.rs @@ -298,6 +298,101 @@ fn replacing_a_file_that_opening_left_things_out_of_asks_first() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn a_save_does_not_replace_the_file_read_from_through_a_file_written_beside_the_path() { + let d = dir("lossy-beside"); + let path = d.join("Art.eps").to_string_lossy().to_string(); + let mut app = app(); + checks_files_there(&mut app); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut app); + let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false}); + let _ = app.run("file.saveAs", json!({"path": path, "options": options})); + crate::background::wait_all(&mut app); + let siblings = artboard_files(&d); + assert_eq!(siblings.len(), 2, "{siblings:?}"); + let sibling = siblings[0].clone(); + let saved = std::fs::read(&sibling).unwrap(); + std::fs::remove_file(&path).unwrap(); + let st = app.session.doc_mut().unwrap(); + st.imported_from = Some(sibling.clone()); + st.import_losses = vec!["hidden type".into()]; + rect(&mut app); + let _ = app.run("file.saveAs", json!({"path": path, "options": options})); + crate::background::wait_all(&mut app); + // The document's last save wrote the files beside the path, so the save does not ask whether + // to replace them: it is refused. + assert!(app.ui.dialog.as_ref().is_none_or(|d| d.kind != dialogs::confirm::REPLACE_FILES)); + assert_eq!(std::fs::read(&sibling).unwrap(), saved); + assert!(!std::path::Path::new(&path).exists(), "no file of the set is written when one is refused"); + let _ = std::fs::remove_dir_all(d); +} + +/// The app checks for files that are there, as the desktop app does. +fn checks_files_there(app: &mut VectorcraftApp) { + app.services.exists = Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())); +} + +/// The files in `d` that an EPS save of `Art.eps` with Use Artboards wrote beside it, sorted. +fn artboard_files(d: &std::path::Path) -> Vec { + let mut files: Vec = std::fs::read_dir(d) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("Art_"))) + .map(|p| p.to_string_lossy().to_string()) + .collect(); + files.sort(); + files +} + +#[test] +fn an_eps_save_with_use_artboards_asks_before_replacing_an_artboard_file_that_is_there() { + let d = dir("eps-artboards-there"); + let path = d.join("Art.eps").to_string_lossy().to_string(); + let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false}); + let save_as = |app: &mut VectorcraftApp| { + let _ = app.run("file.saveAs", json!({"path": path, "options": options})); + crate::background::wait_all(app); + }; + // Another document's save leaves files with the names of the artboard files. + let mut other = app(); + other.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + save_as(&mut other); + let there = artboard_files(&d); + assert_eq!(there.len(), 2, "{there:?}"); + std::fs::remove_file(&path).unwrap(); + for f in &there { + std::fs::write(f, b"other").unwrap(); + } + let mut app = app(); + checks_files_there(&mut app); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut app); + save_as(&mut app); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.kind, dialogs::confirm::REPLACE_FILES); + assert_eq!(dialog.fields["files"], json!(there)); + assert!(!std::path::Path::new(&path).exists(), "nothing is written while it asks"); + assert!(there.iter().all(|f| std::fs::read(f).unwrap() == b"other")); + // Cancel writes nothing. + dialogs::cancel(&mut app); + assert!(!std::path::Path::new(&path).exists()); + assert!(there.iter().all(|f| std::fs::read(f).unwrap() == b"other")); + // OK writes every file. + save_as(&mut app); + dialogs::confirm(&mut app).unwrap(); + crate::background::wait_all(&mut app); + assert!(std::path::Path::new(&path).exists()); + assert!(there.iter().all(|f| std::fs::read(f).unwrap().starts_with(b"%!PS"))); + // The document's next save writes them again without asking. + rect(&mut app); + let _ = app.run("file.save", json!({})); + crate::background::wait_all(&mut app); + assert!(app.ui.dialog.is_none()); + assert!(!app.session.active().unwrap().is_dirty()); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn native_saves_stream_into_the_file() { let d = dir("stream"); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6a90cdfac0..b1788e1e8a 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -12,6 +12,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_tools::{Mods, TOOL_GROUPS, ToolInfo, ToolKey, tool_info}; +use crate::panels::learn::locate as learn_locate; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, icons, widgets}; @@ -85,6 +86,17 @@ pub(crate) fn slots(app: &VectorcraftApp) -> Vec<(Option<&'static str>, Vec<&'st } } +/// The tool a slot's button shows: the active tool when the slot holds it, else the one chosen +/// last from the slot's group, else its first. +pub(crate) fn shown_tool(app: &VectorcraftApp, slot: &[&'static str]) -> &'static str { + let active = app.session.tool_id(); + if slot.contains(&active) { + return active; + } + let first = slot.first().copied().unwrap_or_default(); + app.ui.slot_tool.get(first).and_then(|id| slot.iter().copied().find(|s| s == id)).unwrap_or(first) +} + /// Remember the tool shown in the slot that contains `id`. pub fn remember(app: &mut VectorcraftApp, id: &str) { for (_, s) in slots(app) { @@ -253,6 +265,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option = None; let labels = app.session.prefs.tool_group_labels; + let locate = learn_locate::wanted(&app.ui, None); let mut i = 0; while i < all.len() { if all[i].0.is_some() && !labels { @@ -281,12 +294,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option 1 && is_floating(app, None, slot[0])) + { + if !ui.clip_rect().contains_rect(rect) && learn_locate::scroll_once(&mut app.ui) { + ui.scroll_to_rect(rect, None); + ui.ctx().request_repaint(); + } else if shown.id == tool { + learn_locate::mark(app, ui.ctx(), ui.layer_id(), ui.clip_rect(), rect); + } else if learn_locate::reveal_once(&mut app.ui) { + open_flyout = Some((slot.clone(), rect)); + } + } if editing { buttons.push((k, rect)); resp.dnd_set_drag_payload(crate::edit_toolbar::ToolDrag { tool: shown.id, from_toolbar: true }); @@ -416,8 +438,7 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result Ok(json!({ "open": tool })), - "gradient" | "magicWand" => app.run("window.panel", json!({ "panel": tool })), + "gradient" | "magicWand" => crate::panel_docking::reveal_panel(app, tool), "artboard" => crate::dialogs::artboard_options::open(app), "eyedropper" => { crate::dialogs::eyedropper::open(app); @@ -723,20 +744,62 @@ struct FlyoutInput { bar: Option, /// A floating flyout's × was clicked. close: bool, + /// Where the tool a Learn link locates is drawn. + located: Option, + /// What a docked flyout's rows are clipped to (they scroll in a group taller than the window). + located_clip: Option, } /// A flyout's tool rows (a floating flyout's strip of tool buttons) with the grab bar down their /// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag /// that tears a flyout off carries on moving the floating flyout it becomes (`toolbar`: the toolbar -/// made with New Toolbar the group is in, none for the Tools panel's). -fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool, toolbar: Option<&str>) -> FlyoutInput { +/// made with New Toolbar the group is in, none for the Tools panel's; `locate`: the tool a Learn +/// link locates, whose place it returns; `reveal_row`: a located row outside the flyout's view +/// scrolls into it, and is returned once it shows). +#[allow(clippy::too_many_arguments)] +fn flyout_body( + ui: &mut Ui, + t: &Tokens, + active: &str, + tools: &[String], + floating: bool, + toolbar: Option<&str>, + locate: Option<&str>, + reveal_row: bool, +) -> FlyoutInput { let mut out = FlyoutInput::default(); let key = tools.first().map_or("", String::as_str); let bar_id = bar_id(toolbar, key); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); ui.horizontal(|ui| { - let rows = if floating { ui.horizontal(|ui| tool_strip(ui, t, active, tools)) } else { ui.vertical(|ui| tool_rows(ui, t, active, tools)) }; - out.chosen = rows.inner; + let rows = if floating { + ui.horizontal(|ui| tool_strip(ui, t, active, tools, locate)) + } else { + // A group taller than the window scrolls its rows (inside the frame's 1 pt border). The + // rows' height is given, since the row this sits in starts one widget tall. + let max = ui.ctx().content_rect().height() - 2.0; + let height = (tools.iter().filter(|id| tool_info(id).is_some()).count() as f32 * ROW_HEIGHT).min(max); + ui.vertical(|ui| { + egui::ScrollArea::vertical() + .id_salt(bar_id.with("rows")) + .max_height(max) + .min_scrolled_height(height) + .show(ui, |ui| { + let (chosen, located) = tool_rows(ui, t, active, tools, locate); + // The mark is clipped to the rows' view, not to the whole flyout. + out.located_clip = Some(ui.clip_rect()); + match located.filter(|r| reveal_row && !ui.clip_rect().contains_rect(*r)) { + Some(r) => { + ui.scroll_to_rect_animation(r, Some(egui::Align::Center), egui::style::ScrollAnimation::none()); + (chosen, None) + } + None => (chosen, located), + } + }) + .inner + }) + }; + (out.chosen, out.located) = rows.inner; let (bar, _) = ui.allocate_exact_size(vec2(BAR, rows.response.rect.height()), Sense::hover()); ui.painter().rect_filled(bar, 0.0, t.tab_strip); let grip = if floating { @@ -769,9 +832,11 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating out } -/// One row per tool: icon, name and shortcut, with a dot by the active tool. Returns the tool clicked. -fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> { +/// One row per tool: icon, name and shortcut, with a dot by the active tool. Returns the tool clicked +/// and the row of `locate`. +fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], locate: Option<&str>) -> (Option<&'static str>, Option) { let mut chosen = None; + let mut located = None; let lang = crate::i18n::current(); let color = |tool: &ToolInfo| if active == tool.id { t.flyout_active } else { t.text_strong }; let rows: Vec<(&ToolInfo, RowText)> = @@ -779,7 +844,7 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option< let w = rows_width(rows.iter().map(|(_, text)| text)); for (tool, (name, shortcut)) in rows { let is_active = active == tool.id; - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW_HEIGHT), Sense::click()); if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } @@ -797,8 +862,11 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option< if resp.clicked() { chosen = Some(tool.id); } + if locate == Some(tool.id) { + located = Some(r); + } } - chosen + (chosen, located) } /// A flyout row's name and its shortcut, laid out. @@ -809,6 +877,8 @@ type RowText = (Arc, Option>); const NAME_X: f32 = 52.0; const NAME_GAP: f32 = 14.0; const ROW_END: f32 = 18.0; +/// The height of a flyout row. +const ROW_HEIGHT: f32 = 30.0; /// The name of `tool` in `lang` and its shortcut in brackets, as a flyout row draws them. fn row_text(ui: &Ui, lang: crate::i18n::Lang, tool: &ToolInfo, color: Color32) -> RowText { @@ -842,9 +912,10 @@ fn grab_id(toolbar: Option<&str>, key: &str) -> egui::Id { } /// One icon button per tool, the active one lit, with its name and shortcut on hover. Returns the -/// tool clicked. -fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> { +/// tool clicked and the button of `locate`. +fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], locate: Option<&str>) -> (Option<&'static str>, Option) { let mut chosen = None; + let mut located = None; for tool in tools.iter().filter_map(|id| tool_info(id)) { let (r, resp) = ui.allocate_exact_size(STRIP_BUTTON, Sense::click()); let well = r.shrink(1.0); @@ -859,8 +930,11 @@ fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option if resp.on_hover_text(tip(tool)).clicked() { chosen = Some(tool.id); } + if locate == Some(tool.id) { + located = Some(r); + } } - chosen + (chosen, located) } /// The width of a floating flyout's strip of `tools`, its bar included. @@ -908,14 +982,22 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let active = app.session.tool_id(); let toolbar = ctx.data(|d| d.get_temp::>(flyout_toolbar_id())).flatten(); + let locate = learn_locate::wanted(&app.ui, toolbar.as_deref()).filter(|tool| tools.iter().any(|id| id == tool)); + let reveal_row = learn_locate::unmarked(&app.ui); let mut input = FlyoutInput::default(); // Beside the tool, on the side with room for it: toward the canvas from a toolbar docked right. let size = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).map_or(vec2(160.0, 0.0), |r| r.size()); let x = beside(anchor, size.x, 8.0, room(app, ctx)); + // The area stays inside the window (egui's `Area::constrain`, on by default): a flyout opened + // near the bottom moves up, and a group taller than the window scrolls a located row into view. let resp = egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(pos2(x, anchor.top() - 1.0)).show(ctx, |ui| { - flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref())); + flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref(), locate, reveal_row)); }); + if let Some(rect) = input.located { + let clip = input.located_clip.map_or(resp.response.rect, |c| c.intersect(resp.response.rect)); + learn_locate::mark(app, ctx, resp.response.layer_id, clip, rect); + } // Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the // end of the long press that opened the flyout): it floats where the pointer is. let at = ctx.input(|i| i.pointer.interact_pos()); @@ -998,10 +1080,19 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio let active = app.session.tool_id(); let mut chosen = None; let mut closed = None; + // A Learn link locating a tool of a strip raises the strip once, then marks the tool in it. + let locate = learn_locate::wanted(&app.ui, toolbar); + let raise = locate.is_some_and(|tool| strips(&app.ui, toolbar).iter().any(|f| f.tools.iter().any(|id| id == tool))) + && learn_locate::reveal_once(&mut app.ui); + let mut located = None; let Some(strips) = strips_mut(&mut app.ui, toolbar) else { return }; for (k, f) in strips.iter_mut().enumerate() { let Some(key) = f.tools.first() else { continue }; let id = strip_area(toolbar, key); + let locate = locate.filter(|tool| f.tools.iter().any(|t| t == tool)); + if raise && locate.is_some() { + raise_floating(ctx, toolbar, key); + } let since = ctx.data(|d| d.get_temp::(id)).map(|t0| now - t0); let flash = since.is_some_and(|s| (0.0..FLASH).contains(&s)); if let Some(s) = since.filter(|_| flash) { @@ -1011,9 +1102,12 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(strip_width(&f.tools), PITCH), |r| r.size()); let pos = crate::tools_float::clamp(f.pos, size, screen); let mut input = FlyoutInput::default(); - egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { - flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar)); + let area = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { + flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar, locate, false)); }); + if let Some(rect) = input.located { + located = Some((area.response.layer_id, area.response.rect, rect)); + } // Dragging the bar keeps the flyout where the pointer holds it (by an offset rather than // each frame's motion, which a just torn-off flyout's first, unseen frame would drop). let grab = grab_id(toolbar, key); @@ -1036,6 +1130,9 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio if let Some(k) = closed.filter(|k| *k < strips.len()) { strips.remove(k); } + if let Some((layer, clip, rect)) = located { + learn_locate::mark(app, ctx, layer, clip, rect); + } if let Some(id) = chosen { app.select_tool(id); } diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index e8e5d8f12c..eb45d08533 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -155,6 +155,15 @@ fn more_tools_open_their_options() { assert!(crate::panel_docking::showing(&app.ui, "magicWand")); open_options(&mut app, "magicWand").unwrap(); assert!(crate::panel_docking::showing(&app.ui, "magicWand")); + // With the panels hidden by Tab, the double-click shows them with the panel. + app.run("window.dock", json!({})).unwrap(); + open_options(&mut app, "magicWand").unwrap(); + assert!(app.ui.dock && crate::panel_docking::visible(&app.ui, "magicWand"), "the Magic Wand panel shows"); + // In Presentation Mode the double-click does not fail, and the panel shows when the mode ends. + app.ui.screen_mode = 3; + open_options(&mut app, "magicWand").unwrap(); + app.ui.screen_mode = 0; + assert!(crate::panel_docking::visible(&app.ui, "magicWand"), "the Magic Wand panel stays in place"); } /// A double-click at `at`, `time` seconds in (a second apart from the last). @@ -1054,3 +1063,338 @@ fn tab_skips_the_grip_and_the_tear_off_bars() { assert!(!stops.contains(&id), "Tab stops on {name}: {stops:?}"); } } + +use crate::panels::learn::locate::{LIFETIME, MARK_WIDTH}; + +/// One frame of the toolbar in a window 400 points wide and `height` tall, then what ends a Learn +/// locate (`learn.locateTool`): the marks the locate drew, and the screen-reader nodes. +fn locate_frame( + app: &mut VectorcraftApp, + ctx: &egui::Context, + time: f64, + events: Vec, + height: f32, +) -> (Vec, Vec) { + fn marks(s: &egui::Shape, color: Color32, out: &mut Vec) { + match s { + egui::Shape::Rect(r) if r.stroke.width == MARK_WIDTH && r.stroke.color == color => out.push(r.rect), + egui::Shape::Vec(v) => v.iter().for_each(|s| marks(s, color, out)), + _ => {} + } + } + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, height))); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), events, screen_rect, ..Default::default() }, |ui| { + show(app, ui); + crate::panels::learn::locate::tidy(app, ui.ctx()); + }); + out.textures_delta.clear(); + let color = Tokens::get(ctx).selection; + let mut found = vec![]; + out.shapes.iter().for_each(|c| marks(&c.shape, color, &mut found)); + let nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + (found, nodes) +} + +/// A context that makes screen-reader nodes. +fn reader_context() -> egui::Context { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + ctx +} + +/// Where the screen-reader node named after `tool`'s tool tip is drawn. +fn tool_rect(nodes: &[egui::accesskit::Node], tool: &str) -> egui::Rect { + let name = tool_info(tool).map(tip).unwrap_or_default(); + let b = nodes.iter().find(|n| n.label().is_some_and(|l| l == name)).and_then(|n| n.bounds()).unwrap_or_else(|| panic!("{name} is drawn")); + egui::Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)) +} + +/// The only mark drawn, on `control`. +fn assert_marks(marks: &[egui::Rect], control: egui::Rect, what: &str) { + let [mark] = marks else { panic!("{what}: one mark, not {marks:?}") }; + let want = control.shrink(1.0); + assert!((mark.min - want.min).length() < 0.5 && (mark.max - want.max).length() < 0.5, "{what}: the mark {mark:?} is on {control:?}"); +} + +/// A Learn link's `learn.locateTool` marks a tool shown on its button, with Tools docked left or +/// right, floating or in two columns; it does not choose the tool, and the mark goes after +/// [`LIFETIME`] seconds. +#[test] +fn a_learn_link_marks_the_tools_button_wherever_tools_is() { + for (right, floating, double) in [(false, None, false), (true, None, false), (false, Some([120.0, 80.0]), false), (false, None, true)] { + let what = format!("right {right}, floating {floating:?}, double {double}"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + (app.ui.toolbar_right, app.ui.toolbar_pos, app.ui.toolbar_double) = (right, floating, double); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(), json!({"tool": "pen", "location": "toolbar", "toolbar": null})); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + assert_marks(&marks, tool_rect(&nodes, "pen"), &what); + assert_eq!((app.session.tool_id(), app.ui.flyout), ("selection", None), "{what}: no tool chosen, no flyout"); + assert_eq!(locate_frame(&mut app, &ctx, 1.0 + LIFETIME - 0.1, vec![], 1200.0).0.len(), 1, "{what}: still marked"); + assert!(locate_frame(&mut app, &ctx, 1.0 + LIFETIME + 0.01, vec![], 1200.0).0.is_empty(), "{what}: the mark has gone"); + assert!(app.ui.learn_locate.is_none(), "{what}"); + } +} + +/// A tool in a collapsed group opens the group's flyout with its row marked, also while another +/// tool of the group shows on the button; the group keeps the tool it shows. +#[test] +fn a_learn_link_opens_a_collapsed_group_on_the_tools_row() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.slot_tool.insert("pencil".into(), "pathEraser".into()); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + for (k, tool) in ["smooth", "pencil"].into_iter().enumerate() { + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap()["location"], "flyout", "{tool}"); + // The flyout's first frame only measures it. + locate_frame(&mut app, &ctx, 1.0 + k as f64, vec![], 1200.0); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.1 + k as f64, vec![], 1200.0); + assert!(app.ui.flyout.is_some(), "{tool}: the flyout opens"); + assert_marks(&marks, tool_rect(&nodes, tool), tool); + let row = tool_rect(&nodes, tool); + let flyout = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout shows"); + assert!(flyout.contains_rect(row), "{tool}: a row of the flyout ({row:?} in {flyout:?})"); + assert_eq!(ctx.data(|d| d.get_temp::>(egui::Id::unique("flyout-tools"))).map(|t| t[0].clone()).as_deref(), Some("pencil")); + } + assert_eq!(app.ui.slot_tool.get("pencil").map(String::as_str), Some("pathEraser"), "the group still shows the Path Eraser"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// A tool of a group torn off into a strip: the strip comes to the front with the tool marked in it. +#[test] +fn a_learn_link_raises_a_torn_off_group_and_marks_the_tool_in_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.floatTools", json!({"tool": "pen"})).unwrap(); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "addAnchor"})).unwrap()["location"], "strip"); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + assert!(ctx.data(|d| d.get_temp::(floating_area("pen"))).is_some(), "the strip was raised"); + let strip = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap(); + let button = tool_rect(&nodes, "addAnchor"); + assert!(strip.contains_rect(button), "the strip's button: {button:?} in {strip:?}"); + assert_marks(&marks, button, "the strip"); + assert_eq!(app.ui.flyout, None); +} + +/// A tool Edit Toolbar took out of the toolbar is marked in the All Tools drawer, which opens; the +/// toolbar stays as it was. +#[test] +fn a_learn_link_marks_a_tool_no_toolbar_holds_in_all_tools() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("toolbar.removeTool", json!({"tool": "rectangle"})).unwrap(); + let before = slots(&app); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "rectangle"})).unwrap()["location"], "allTools"); + assert!(app.ui.tools_drawer && app.ui.drawer_toolbar.is_none()); + // The drawer's first frame only measures it. + locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.1, vec![], 1200.0); + let drawer = ctx.memory(|m| m.area_rect(crate::edit_toolbar::drawer_id())).expect("the drawer shows"); + let row = tool_rect(&nodes, "rectangle"); + assert!(drawer.contains_rect(row), "the drawer's row: {row:?} in {drawer:?}"); + assert_marks(&marks, row, "the drawer"); + assert_eq!(slots(&app), before, "no tool added to the toolbar"); + // Escape closes the drawer first (the shortcuts consume it), and ends the locate too. + let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))); + let _ = ctx.run_ui(egui::RawInput { time: Some(1.2), events: vec![escape], screen_rect, ..Default::default() }, |ui| { + crate::shortcuts::handle(&mut app, ui.ctx()); + show(&mut app, ui); + crate::panels::learn::locate::tidy(&mut app, ui.ctx()); + }); + assert!(!app.ui.tools_drawer, "Escape closed the drawer"); + assert!(app.ui.learn_locate.is_none(), "and ended the locate"); +} + +/// A tool on a toolbar made with New Toolbar that the Tools panel does not hold is marked there. +#[test] +fn a_learn_link_marks_a_tool_on_a_toolbar_made_with_new_toolbar() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "artboard", "toolbar": "Mine"})).unwrap(); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!( + app.run("learn.locateTool", json!({"tool": "artboard"})).unwrap(), + json!({"tool": "artboard", "location": "toolbar", "toolbar": "Mine"}) + ); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + let bar = ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id("Mine"))).unwrap(); + let button = tool_rect(&nodes, "artboard"); + assert!(bar.contains_rect(button), "{button:?} in {bar:?}"); + assert_marks(&marks, button, "the toolbar Mine"); + // A tool of a group whose button shows another: the group's flyout opens on it. + app.run("toolbar.addTool", json!({"tool": "slice", "toolbar": "Mine", "slot": 0, "place": "group"})).unwrap(); + assert_eq!(app.run("learn.locateTool", json!({"tool": "slice"})).unwrap(), json!({"tool": "slice", "location": "flyout", "toolbar": "Mine"})); + locate_frame(&mut app, &ctx, 2.0, vec![], 1200.0); + let (marks, nodes) = locate_frame(&mut app, &ctx, 2.1, vec![], 1200.0); + let flyout = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout shows"); + let row = tool_rect(&nodes, "slice"); + assert!(flyout.contains_rect(row), "{row:?} in {flyout:?}"); + assert_marks(&marks, row, "the flyout of the toolbar Mine"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// Tools hidden with Window › Tools shows again for a Learn link, and a short window scrolls the +/// tool into view. +#[test] +fn a_learn_link_shows_hidden_tools_and_scrolls_the_tool_into_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.toolbar = false; + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 300.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "gradient"})).unwrap()["location"], "toolbar"); + assert!(app.ui.toolbar, "Tools shows"); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 300.0); + } + let (marks, nodes) = drawn; + let button = tool_rect(&nodes, "gradient"); + assert!(button.top() >= 0.0 && button.bottom() <= 300.0, "scrolled into view: {button:?}"); + assert_marks(&marks, button, "scrolled"); +} + +/// A short window scrolls a tool of a toolbar made with New Toolbar into view for a Learn link. +#[test] +fn a_learn_link_scrolls_a_tool_into_view_on_a_toolbar_made_with_new_toolbar() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + let tools: Vec<&'static str> = slots(&app).into_iter().filter_map(|(_, s)| s.first().copied()).take(16).collect(); + for tool in &tools { + app.run("toolbar.addTool", json!({"tool": tool, "toolbar": "Mine"})).unwrap(); + } + let tool = *tools.last().expect("a tool"); + app.run("toolbar.removeTool", json!({"tool": tool})).unwrap(); + assert!(slots(&app).iter().all(|(_, s)| !s.contains(&tool)), "the Tools panel does not hold {tool}"); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 300.0); + let (_, nodes) = locate_frame(&mut app, &ctx, 0.05, vec![], 300.0); + let name = tool_info(tool).map(tip).unwrap_or_default(); + let shown = nodes.iter().filter(|n| n.label().is_some_and(|l| l == name)).filter_map(|n| n.bounds()).any(|b| b.y1 <= 300.0); + assert!(!shown, "{tool} starts outside the window"); + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap(), json!({"tool": tool, "location": "toolbar", "toolbar": "Mine"})); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 300.0); + } + let (marks, nodes) = drawn; + let button = tool_rect(&nodes, tool); + let bar = ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id("Mine"))).unwrap(); + assert!(bar.contains_rect(button), "{button:?} in {bar:?}"); + assert!(button.top() >= 0.0 && button.bottom() <= 300.0, "scrolled into view: {button:?}"); + assert_marks(&marks, button, "scrolled on the toolbar Mine"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// In a short window, the flyout of the last group stays inside the window, so the located row's +/// mark shows. +#[test] +fn a_learn_link_in_a_short_window_marks_a_flyout_row_inside_the_window() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = reader_context(); + let slot = slots(&app).into_iter().map(|(_, s)| s).rfind(|s| s.len() > 1).expect("a group"); + let tool = *slot.last().expect("a tool"); + locate_frame(&mut app, &ctx, 0.0, vec![], 300.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap()["location"], "flyout", "{tool}"); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 300.0); + } + let (marks, nodes) = drawn; + let row = tool_rect(&nodes, tool); + assert!(row.top() >= 0.0 && row.bottom() <= 300.0, "{tool}: the row is inside the window: {row:?}"); + assert_marks(&marks, row, tool); +} + +/// A group with more rows than the window holds scrolls its flyout, and a Learn link scrolls the +/// located row into view before marking it. +#[test] +fn a_learn_link_scrolls_the_row_of_a_group_taller_than_the_window_into_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let tools: Vec<&'static str> = slots(&app).into_iter().filter_map(|(_, s)| s.first().copied()).skip(1).take(16).collect(); + for tool in &tools { + app.run("toolbar.addTool", json!({"tool": tool, "slot": 0, "place": "group"})).unwrap(); + } + let tool = *tools.last().expect("a tool"); + let group = slots(&app).into_iter().map(|(_, s)| s).find(|s| s.contains(&tool)).expect("the group"); + assert!(group.len() * 30 > 400, "{} rows of 30 points do not fit in 400", group.len()); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 400.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap()["location"], "flyout", "{tool}"); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 400.0); + } + let (marks, nodes) = drawn; + let row = tool_rect(&nodes, tool); + assert!(row.top() >= 0.0 && row.bottom() <= 400.0, "{tool}: the row is inside the window: {row:?}"); + assert_marks(&marks, row, tool); + assert_eq!(app.session.tool_id(), "selection"); + // The mark is clipped to the rows' view inside the flyout's 1 pt border, so a row scrolled + // part way out shows no mark over the border above or below the rows. + let view = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout shows").shrink(1.0); + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))); + let out = ctx.run_ui(egui::RawInput { time: Some(2.0), screen_rect, ..Default::default() }, |ui| show(&mut app, ui)); + let color = Tokens::get(&ctx).selection; + let clips: Vec = out + .shapes + .iter() + .filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.stroke.width == MARK_WIDTH && r.stroke.color == color)) + .map(|c| c.clip_rect) + .collect(); + assert!( + !clips.is_empty() && clips.iter().all(|c| c.top() >= view.top() && c.bottom() <= view.bottom()), + "the mark is clipped to {view:?}: {clips:?}" + ); +} + +/// A toolbar made with New Toolbar under another floating toolbar comes to the front for a Learn +/// link, so its marked button shows. +#[test] +fn a_learn_link_brings_a_covered_toolbar_made_with_new_toolbar_to_the_front() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + for (name, tool) in [("Mine", "artboard"), ("Other", "slice")] { + app.run("window.toolbar.new", json!({"name": name})).unwrap(); + app.run("toolbar.addTool", json!({"tool": tool, "toolbar": name})).unwrap(); + } + for bar in &mut app.ui.custom_toolbars { + bar.pos = [200.0, 200.0]; + } + let (mine, other) = (crate::custom_toolbars::area_id("Mine"), crate::custom_toolbars::area_id("Other")); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, other)); + let (_, nodes) = locate_frame(&mut app, &ctx, 0.05, vec![], 1200.0); + let button = tool_rect(&nodes, "artboard"); + assert_eq!(ctx.layer_id_at(button.center()).map(|l| l.id), Some(other), "Other covers the button of Mine"); + assert_eq!( + app.run("learn.locateTool", json!({"tool": "artboard"})).unwrap(), + json!({"tool": "artboard", "location": "toolbar", "toolbar": "Mine"}) + ); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + let button = tool_rect(&nodes, "artboard"); + assert_eq!(ctx.layer_id_at(button.center()).map(|l| l.id), Some(mine), "Mine comes to the front"); + assert_marks(&marks, button, "the toolbar Mine"); +} + +/// A second Learn link moves the mark; Escape removes it. +#[test] +fn a_second_learn_link_replaces_the_mark_and_escape_removes_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(); + let (marks, nodes) = locate_frame(&mut app, &ctx, 0.5, vec![], 1200.0); + assert_marks(&marks, tool_rect(&nodes, "pen"), "pen"); + app.run("learn.locateTool", json!({"tool": "curvature"})).unwrap(); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + assert_marks(&marks, tool_rect(&nodes, "curvature"), "the second link"); + let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + locate_frame(&mut app, &ctx, 1.5, vec![escape], 1200.0); + assert!(app.ui.learn_locate.is_none()); + assert!(locate_frame(&mut app, &ctx, 2.0, vec![], 1200.0).0.is_empty()); +} diff --git a/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs index 1564025a41..f130795c0f 100644 --- a/crates/ui-egui/src/widgets/drag.rs +++ b/crates/ui-egui/src/widgets/drag.rs @@ -4,12 +4,13 @@ use super::{Paint, Rect, Response, Stroke, StrokeKind, Tokens, Ui, Vec2}; /// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer. #[derive(Clone, Debug, PartialEq)] pub enum PanelDrag { - /// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it - /// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group, - /// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the - /// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes - /// a swatch of a paint dragged from elsewhere. `library`: the Libraries panel colour it is - /// (library id, colour name), which that panel moves to where it is dropped. + /// A paint: Swatches panel tiles, a Fill/Stroke proxy, a Recent Colors chip or the Gradient + /// panel's thumbnail. Art it is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; + /// null for a colour group, which paints nothing) through the active proxy's + /// `paint.setFill`/`paint.setStroke`; the Gradient panel's ramp takes its colour as a stop; the + /// Swatches panel moves `rows`, or makes a swatch of a paint dragged from elsewhere. `library`: + /// the Libraries panel colour it is (library id, colour name), which that panel moves to where + /// it is dropped. Paint { paint: Paint, params: serde_json::Value, rows: Option, library: Option<(String, String)> }, /// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance /// (`appearance.copyFrom`); the Graphic Styles panel makes a style of it. @@ -68,9 +69,9 @@ pub struct SwatchRows { } impl PanelDrag { - /// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient - /// carries no placement: it fits the art it lands on (freeform points are placed afresh there, - /// coloured like the dragged ones). + /// `paint` dragged from a Fill/Stroke proxy, a Recent Colors chip or the Gradient panel's + /// thumbnail. A gradient carries no placement: it fits the art it lands on (freeform points are + /// placed afresh there, coloured like the dragged ones). pub fn paint(mut paint: Paint) -> Self { if let Paint::Gradient(g) = &mut paint { g.geom = None; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9db1e9f90a..a9880a37dd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,8 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> +> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -20,8 +22,6 @@ Ordinary File > Save offers Save As when its own location refuses writes with a | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | -| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) | -| Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 18 | missing: DWG; extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -56,7 +56,10 @@ private editing data. VectorCraft: own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change. Type from the text document keeps auto leading as auto, each line taking the paragraph's share (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), - and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Area type of one + and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Type filled or + stroked with a pattern keeps its pattern, placed in the document as a path's pattern fill would be: a character's paint names + an art style of one paint, and the pattern is in that style (its colour in the text document is black). A pattern the file + doesn't define, or a style that isn't a pattern, keeps the colour as before. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` @@ -64,6 +67,7 @@ private editing data. VectorCraft: open with different, unconverted RGB colours than before. A PDF-compatible `.ai` opens on its PDF part's trim boxes (its artboards), the bleed boxes round them becoming the document's bleed, so art in the bleed shows outside the artboard, within the bleed guide (#1266). + With editing data explicitly requested, a single artboard larger than its 14,400 pt capped PDF page opens at its full size, with a warning (#6865). A PDF saved with its editing data (`AIPDFPrivateData`) opens the same way. The swatches of a version 7 `.ai` palette, which carry no names, come in named by their spot color, gradient or values. @@ -248,42 +252,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; 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, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), 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 on a Stroke Offset stroke or written as each side's widths) 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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), a variable-width stroke with one centered width array, 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). 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), 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; 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). 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), 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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). 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), 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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). 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), 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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 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). 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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 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). 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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 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). 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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). 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), 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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). 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), 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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). 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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). 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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). 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; 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, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), 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 on a Stroke Offset stroke or written as each side's widths) 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) 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), a variable-width stroke with one centered width array, 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). 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), 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. 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 run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). 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. @@ -302,7 +271,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | -| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | +| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open | | HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | @@ -353,6 +322,8 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern. In the text document a character's fill or stroke is a colour (key 0) and the name of an art style (key 5). In users' files (used locally, never committed) that style is one paint (`/SimpleStyle`): a colour, the one key 0 already has, or a pattern, `(name) … [matrix] p`, whose key 0 is black. Such type came in black, or stroked in black. It now takes the pattern, its matrix in art space as a path's pattern fill has it (the files' PDF parts tile such type with that matrix), placed in the type's text space so that it stays where the document has it. A pattern the file doesn't define or can't place, and a style of any other paint, keep the colour as before. A `.ai` saved again writes such type as its outlines, as it writes all type with pattern paint, with that warning. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 157 in 46, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 10 of the 13 files with patterned type, 8 closer to the file's own page (one by under 0.01 points, in a file whose render differs from its page almost everywhere either way), 1 further by 0.03 points of its pixels (an envelope of type there now opens live in place of its drawn art), 1 not compared (its page renders at another size); notes change in 3 files: one loses its type note, one loses the note for type in an envelope and, now compared with its page, gains its type notes, and one that opens from its PDF part names a smaller difference. Tests `ate::tests::a_run_painted_by_a_patterns_art_style_takes_the_pattern`, `a_runs_pattern_stays_where_the_document_has_it`, `tests_native::type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names`. Scores and estimates unchanged. (#6856) | +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | PDF import (#5235): a glyph with no advance in a font the file writes horizontally (a vowel sign or anusvara set over its letter) stays on its line instead of opening as vertical type of its own. A line of type in an Indic script (U+0900 to U+0DFF) is set again after it is read and compared with the glyphs the file drew: when it would draw other glyphs or put them elsewhere (a vowel sign the file draws before its consonant, a conjunct the file doesn't name), the line keeps the file's glyphs as outlines, with a note. Indic type in a font that isn't installed never draws as the file does, so it keeps the file's glyphs too; other type in such a font still shows in the fallback font (#4568). Tests: `crates/pdf/src/tests/reshaped.rs`. Scores unchanged. | | 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | diff --git a/docs/gaps.md b/docs/gaps.md index 09c31c8ae8..826de6fe24 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -195,7 +195,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Narrowed (#6165, 2026-11-09):** editable PDF import preserves explicit ActualText once, with empty/nested spans, authored order and glyph advances covered by `crates/pdf/src/tests/actual_text.rs`. One replacement uses the first placed glyph's line/style; exact redistribution across lines/styles and general mixed-direction layout remain. Input is bounded to depth 128 and 64 KiB encoded per accepted replacement. This does not close G9's broader `.ai`/EPS gaps or change its estimate. -- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined CMaps (Unicode, Shift-JIS, EUC, GBK, Big Five, UHC) set as type, vertical over `-V` CMaps) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. +- **Narrowed (2026-11-12):** named CIDFonts over predefined CMaps other than Unicode CMaps (Shift-JIS, EUC, GBK, Big Five, UHC) are set as type too, vertical over `-V` CMaps, also by `charpath`, `cshow`, `kshow` and `glyphshow`. This narrows G9; scores and estimates unchanged. +- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. - **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. @@ -212,6 +213,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi stroke. Other Art brushes (None or another colorization, another width, Proportional, between guides, flipped, art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. +- **Done (2026-11-12):** Explicitly requested editing data retains a single artboard larger than a capped 14,400 pt PDF page (#6865); the default import and other size mismatches remain unchanged. Synthetic importer regression; scores unchanged. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form @@ -357,8 +359,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Patterns and Scale Strokes & Effects: off, Transform Objects leaves the geometry in place; the other two scale stroke weights, dashes and the fills' and strokes' effect distances and move pattern tiles with the object, copy `k` by the transformation `k` times. Canvas, export and Expand Appearance agree. A Transform effect in a fill's or - stroke's own effect list applies the two options to that fill or stroke and its later effects in the same way. - Not yet: object effects after it keep their distances. + stroke's own effect list applies the two options to that fill or stroke; the effects listed after it keep their + distances and apply to the transformed art, as an object's later effects do. - **Done (#937, reproduced case):** Shape Builder regions on art of more than 4,096 segments (traced or type outlines): the planar map no longer gives up there, so lines cut such art instead of the tool offering whole shapes (the planar map's edge-source and face lookups were quadratic in the segments, now indexed, and the cap is 65,536; Divide with open paths shares it). The edge-source index files a long segment under the grid cells along it, so a diagonal hatch (every line's bounds hold most points) no longer costs a scan of the hatch per edge. The grid holds a budget of references (64 per item plus four rows' or columns' worth), the items past it scanned, so coincident segments can't make it quadratic in memory. @@ -536,8 +538,10 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi (#1676). A linked image is refused, as the public documentation has it. - **Partial:** blend easing (#1124, 30.7): Window › Blend has Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift; easing is disabled without a spine. - The original polynomial distributions approximate the public description. On-canvas easing handles - and their synchronization remain missing. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). + The original polynomial distributions approximate the public description. With the Selection or + Blend tool, on-canvas widgets on a blend with a spine set the color Shift and, when the steps follow + that spine, the Ramp; a blend made without a spine shows neither widget. The Blend panel shows the + values they set. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). - **Done:** gradient Dither (30.0): the Gradient panel's Dither box (`paint.editGradient` `dither`, also in `paint.setFill`'s `gradient`, swatches and the native file) draws a linear or radial gradient fill as pixels with seeded noise of up to a @@ -634,7 +638,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (eight original libraries generated in code: Art Ribbons, Calligraphic Nibs, Bristle Brushes (one + its own (nine original libraries generated in code: Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes (one bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails, Energy Beams and Dry Media (grainy chalk, pencil, crayon and charcoal art brushes); User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo @@ -861,6 +865,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. | | 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. | | 2026-11-12 | minor | G11: numeric fields keep the caret when a focus click is released more than 1 pt from its press; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326); short-drag and jitter-tap regressions in `tests/numfields.rs`; estimates unchanged. | diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 77086c5031..11de4fbda6 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -87,6 +87,16 @@ pixels and 16,384 pixels on a side. A raster export shows the line as the canvas Type Optimized anti-aliasing, or when it uses Art Optimized and is larger than either limit. Overlapping stacks the colors instead: a lower color continues under the edge of the color above it, and no background shows along that edge. + +**Flat Logo with named colors.** With `logoColors` named, Flat Logo decides whether the image is flat +art by the image's own colors. It finds them the same way as when no colors are named, and looks for as +many colors as are named, with white, or as the Colors setting gives, whichever is more. How far the named colors +are from the image's colors does not change whether the image is refused. While no more colors are +named than the Colors setting, an image is refused exactly when it is refused without named colors. +The trace then uses only the named colors, and white while white is a color of its own. An area of a +color that was not named is traced too: each of its pixels goes to the named color, white or +transparent that has the largest share in the blend of them that comes closest to the pixel. + **Large images.** An image of more than 1500 pixels on a side is traced as any other, only more slowly, and the trace says so: `imageTrace.make` returns the note in `warnings` and the status bar shows it (the [results optimization](https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html) diff --git a/docs/mcp.md b/docs/mcp.md index 6e0acdcaf5..fc9a2a2d28 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -145,24 +145,64 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` + in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`, + `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, + `destinationFolder`), the entries of a list under such a param, and under any other param, as a key or in any + list, an absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that + names another host is always left out), and `~`, a path under `~` or a path that starts with `./` or `../` + (either separator) that resolves outside the roots from the app's working folder; and the folder + preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the - Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is - closed. When another hidden library has the same file name, the libraries take `loaded/<file name>`, - `loaded/<file name> (2)`, `(3)` and so on in the order of their full ids (the libraries loaded in this session - first, then those only the UI state names), and each one keeps its id in both replies and in the library - commands below. The other entries keep their order, and the app's own state is not changed. + Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` + params of a recorded action's steps in `ui.action_sets`, and the keys of shortcut overrides (`panel:<panel id>` + in `ui.shortcut_overrides` and an open Keyboard Shortcuts dialog's `ui.dialog.fields.overrides`), also when its + panel is closed. A path written with + either separator counts as a path, also one saved on another platform, and outside Windows a path that starts + with a drive (`C:`) or a `\` counts as outside the roots. Outside Windows, any other path keeps a `\` in its + file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`), and a reply matches it only as written, not with `/` + in place of the `\`. When another hidden library has the same + file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they + are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the + libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest + of the session in both replies and in the library commands below, also after another library with the same + file name is loaded. A hidden library skips an id that a library loaded without a path already has, and only a + library loaded later from data under that file name, which takes the id itself, moves a hidden one to the next + free number. The other entries keep their order, and the app's own state is not changed. A request may name such a library or its panel by the id + shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `target`, `before`, `onto`, + `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` + reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path + appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its + path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query + (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal + fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` + URL writes it, and on Windows in any case of its letters; a `file:` URL of an absolute path is shown as the file + name alone, without `file:` or its host. A hidden id or path stays only where it begins + another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the + read roots that the request sent by its file name, whether or not a library was loaded from it and whether the + request succeeded or failed, so that the reply reads the same either way. The one exception is the `path` + and `files` entries of a reply's result, a `command.batch` step's result included, that name the file the + request wrote inside the write roots at the path the request gave: they stay as + given, as for the save and export replies above. A path param (`path`, `file`, + `destinationFolder` and the like) counts with a relative path too. The hidden libraries' ids and paths are + matched before the paths the request sent, so a sent path written over part of a hidden library's path does not + keep that path in the reply. A relative path is not replaced where it ends a longer path (`/work/data/icon.svg` + keeps `data/icon.svg`), and the absolute path it reads as from the app's working folder is replaced too. Encoded file data, a base64 string under `dataBase64`, + `pngBase64`, `thumbnailBase64` or `thumbnail` (`library.get`, `document.pdfInfo`), is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered `(2)`, `(3)` and so on as in `ui_inspect` when two have the same file name. That id finds the library in the other library commands (`swatch.library.get`, `swatch.library.add`, `brush.library`, `brush.addFromLibrary`, `symbol.library`, `symbol.addFromLibrary`, `graphicStyle.library`, `graphicStyle.addFromLibrary` and the - others that take `library`), whose replies show it too. `swatch.library.copyToUser` refuses a library whose file - is outside the read roots without naming the file, and it and the library saves with `user: true` leave out - `path` when the user library folder is outside the read roots. + others that take `library`), whose replies show it too. Over the control channel, `window.swatchLibrary` and + the other library panel commands answer with that id and its panel's id (`library:swatches:loaded/<file name>`), + and `ui.libraryPanel` and the `window.panel.*` commands take that panel id in `panel`, `anchor`, `before`, + `onto`, `above` and `below`. `swatch.library.copyToUser` refuses to copy a library file outside the read roots + without naming the file (the swatches of a document loaded from there are written from the loaded library, as + for a library loaded from data, and the document is not read again), and it and the library saves with + `user: true` leave out `path` when the user library folder is outside the read roots. - `links.list`, `links.check`, `links.info` and the `missingLinks`, `modifiedLinks` and `updatedLinks` rows of `document.open` leave out a linked file's path when the file is outside the read roots (a relative path is taken from the document's folder): `path`, `found`, and in `links.info` also `link` and `location`, with the `fileSize` @@ -182,6 +222,13 @@ the suite) keep the agent to the folders you give it: `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and `prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the read roots, and the preset stays. The preferences themselves are not changed. +- `document.inspect` (`inspect_document`) gives `path` as null when the document's file is outside the read roots. + `document.json` names a linked image or placed file outside them by its file name alone in the link's `path`, and + leaves out the link's `relative`, `size` and `modified`. This covers the art of the brushes in `unknown.brushes` + too. A link whose file is inside them keeps its `path` but leaves out a `relative` that leads outside them. A + stored brush entry that does not read as a brush is left out of `unknown.brushes`. `document.json` also names a data set's image + value outside them by its file name alone, and leaves out the `folder` of `export_settings` (the settings Export + for Screens last used) when that folder is outside them. The document itself is not changed. - `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source` @@ -217,6 +264,12 @@ the suite) keep the agent to the folders you give it: `outside the write roots /work/project/out, /tmp/scratch: …`) or `automation filesystem access is not granted: write authority is absent: /work/project/out/x.png`. Links in an opened document that point outside the read roots show as missing. +- An error names a path the client gave as given. A path the client did not give (the document's own file + in a save without `path` and in `file.revert`, also inside `action.batch`, an export preset's folder, the + Scripts folder, the document's folder in `links.relink {copy}`) is named + whole only inside the read roots; outside them the error names it by its file name, or as + `a file or folder outside the read roots` when it has none, and keeps the roots and the reason, for example + `automation path rejected: outside the write root /work/project/out: poster.vectorcraft`. - The preferences that move folders the app reads or writes on its own (`fontsFolder`, `pluginsFolder`, `recoveryFolder`, `templatesFolder`) can only be set inside the roots. The app's own folders (preferences, Data Recovery, the User Defined library folders, VectorCraft's Fonts folder) are not @@ -777,7 +830,20 @@ lists every option with its default. `encoding` is `utf8`, `utf16` (big-endian a a warning). `embedFonts: true` embeds the fonts type uses as `@font-face` rules subset to the characters used; a font whose licence (OS/2 `fsType`) forbids subsetting is embedded whole, one that forbids embedding is left out with a warning. Type names its faces by numeric `font-weight` (600 for Semibold and for a Japanese face's W6, 300 for Light, `bold` for 700), -embedded or not. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open, +embedded or not. `variableData: true` (SVG Options › Include Variable Data) writes the document's text variables and +data sets as a `variableSets` element in the layout `variable.loadLibrary` reads, so the SVG works as a data merge +template ([help](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/merge-data.html)). +Each text variable names its bound type object by the element's id, `docRef="id('…')"` (VectorCraft's own form); an +unnamed object, or any object with `objectIds: "minimal"`, is given an id for it. A variable bound to several type +objects names the first by object id. Variables of other kinds, text variables whose names aren't XML names, and data +sets whose names are blank, have white space at either end, hold characters XML can't hold or repeat a name are left +out, each with a warning. Only the first 256 text variables and the first 1,000 data sets are written, and variable +data larger than 8 MiB or holding more than 2,000,000 XML nodes is left out (the limits `variable.loadLibrary` +reads), each with a warning. With `outlineText`, the bound type is written as outlines, so its text variables open +unbound (with a warning). `document.open` reads the variable data of an SVG back (unless the SVG reopens from +Preserve Editing): its text variables and data sets, no active data set, and each text variable bound to the type +object its `docRef` names; the art stays as the file draws it. Variable data that `variable.loadLibrary` would refuse +is left out with the warning "the file's variable data wasn't read", and the art opens. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open, place and paste). `run_command document.save {path: "x.svg", svg: {…}}` saves as SVG (or `.svgz`). Colorized grayscale and 1-bit images export in their fill color; linked mode writes a coverage PNG, and Tiny 1.2 writes a colorized PNG. @@ -1452,6 +1518,11 @@ key, the Batch dialog or `ui.action.play`), a key a command no longer reads, rec and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect `color` or `channel` it can't read. +`window.panel` refuses every request in Presentation Mode, which hides every panel, and changes nothing. With +`toggle` it hides only a panel that can be seen, which is the panel the Window menu checks. While Tab or Shift+Tab +hides the dock, no panel is checked, and the panel shows again with the dock in its earlier place; the toolbar and the +Control bar stay as they are. + The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export` and `file.export` refuse a key that no format takes, and ignore a key that only another format takes (`{"format": "pdf", "quality": 80}` writes the PDF). `document.exportPdf`, `document.pdfSettings` and `pdf.preset.save` @@ -1631,7 +1702,9 @@ wrong. A value that is not a number gets `` `dx` must be a number, not "10" ``: `` `size` must be a number, not true ``, and a `tracking` that is neither a number nor a number written as a string (`"20"`) with `` `tracking` must be a number, not false ``. It and `text.setRangeStyle` refuse a `font` or `style` that is not a string with `` `font` must be a string ``, and a `features` list with an entry that is not a string with -`features must be a list of tags`; `text.setStyle` refuses such a `justify` too. `type.ruby` refuses a `text`, +`features must be a list of tags`; `text.setStyle` refuses such a `justify` too. `text.create`, `text.createInPath`, +`text.setStyle` and `text.setRangeStyle` refuse a `size` outside 0.1 to 1,296 pt with +`` `size` must be from 0.1 to 1296 `` and change nothing. `type.ruby` refuses a `text`, `position` or `align` that is not a string, and `text.setPathHandle` refuses a `which` other than `in` or `out` with `` `which` must be one of in, out ``. Each leaves the document, the selection and the undo history as they were, also when the call gives another param it could apply. A null is read as a key left out. @@ -1956,8 +2029,9 @@ no engines, and the engines it left are dropped when their own thread next uses in the earlier run's script, a file it included or a file its `$.evalFile` ran, and the report's `file` names that file. For an earlier script given as `source`, `file` is that run's name. The report's `name` and the console line's name stay the calling run's, as in `caller.js: /path/lib.jsx:12:5: Error: kept`. Each engine keeps these places - for 8 MiB of source maps, the oldest going first; an error in code whose place is gone, or in code that `eval` or - `Function` compiled, is reported without a line and column, and the code still runs. + for 8 MiB of source maps, the oldest going first, and a run whose script does not compile keeps none. An error in + code whose place is gone, or in code that `eval` or `Function` compiled, is reported without a line and column, and + the code still runs. - `vc`, `$`, `alert`, `confirm` and `prompt` act for the run that calls them, including a copy an earlier run kept in a global. `vc.args` is the calling run's `args`, through a `vc` object an earlier run kept too. An engine holds no document: `vc.document` is a copy, read when it is called. What a run sets on `$` (`$.locale`, @@ -2558,6 +2632,24 @@ and changes nothing. Behavior reference: {"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}} ``` +A step that names a tool shows the tool's name as a blue link. A click on it, or Enter or Space while it has the +keyboard focus, runs `learn.locateTool {tool}`, which marks where the tool is in the current layout without choosing +it: its toolbar button, or the tool's row in its group's flyout, which opens when the button shows another tool of the +group. A group torn off into a strip comes to the front with the tool marked in it. A tool that no shown toolbar holds +is marked in the All Tools drawer, which opens; the tool is not added to a toolbar. A Tools panel hidden with +Window › Tools shows again. A target below the window's bottom scrolls into view. The mark lasts 3 seconds after it +first shows; Escape or the next locate removes it. The reply is `{tool, location, toolbar}`: `location` is `toolbar`, +`flyout`, `strip`, `allTools` or `unavailable`, and `toolbar` names the toolbar made with New Toolbar that holds the +tool (null for the Tools panel's). While Presentation Mode or Tab hides the toolbars, the reply is `unavailable` with +a `reason`, and nothing changes. A missing, non-string or unknown `tool` is an error and changes nothing. The mark, +its 3 seconds, the flyout, strip and All Tools behavior and the hidden-toolbar replies are VectorCraft's own rules; +the help page (https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html) +describes selecting a tool's name in a step to find the tool. + +```json +{"name":"invoke_menu","arguments":{"command":"learn.locateTool","params":{"tool":"smooth"}}} +``` + ## Ruler guides Each ruler guide is an object of a layer or sublayer, directly in it and above its art, and takes an id from the @@ -2870,16 +2962,23 @@ already walks Image Trace. Short agent entry points: use version 2, so older builds refuse them instead of playing the excluded steps or failing at a stop; other files use version 1. A file with another `format` or a `version` other than 1, 2 or 3, a stop whose message is not text or whose `allowContinue` is not true or false, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are - refused, and no set is added. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is + refused, and no set is added. So is a file whose set `ui.actionSet.save` would write larger than 8 MiB (a file with + less white space, a set name that gets a number, or an `.aia` set), with the error `the action set would be larger + than 8 MiB`. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is disabled where there is no open dialog (the web). `ui.actionSet.load` also reads the action sets other apps save as text (`.aia`); see "Action sets from other apps". - Actions: `ui.action.rename {set, action, name}` renames the Actions panel's action at `action` in set `set` (0 is the top set) to `name` with spaces trimmed from both ends, keeps its steps, function key and color, and returns - `{name}`. An index past the last set or action, or an empty name, is an error, and then nothing is renamed. + `{name}`. An index past the last set or action, or an empty name, is an error, and then nothing is renamed. So is a + name that would give the set's action set file more than 8 MiB, counting the steps the recording in progress adds + to the set (`the action set would be larger than 8 MiB`); a name that leaves the file as large or makes it smaller + is never refused. The same holds for `ui.action.options` and for the names Set Options… and New Set… give. - Actions: `ui.action.stepsIncluded {set, action?, step?, included, others?}` includes or excludes all steps of a set, one action, or one step. With `step` and `others: true`, it leaves that step as it was and applies `included` to the - other steps of its action (the panel's Alt/Option-click). An invalid set, action or step changes nothing. The panel's - checkmarks run this command; normal playback and Batch… skip excluded steps. + other steps of its action (the panel's Alt/Option-click). An invalid set, action or step changes nothing. So does an + exclusion that would give the set's action set file more than 8 MiB, counting the steps the recording in progress + adds to the set; it is an error. A change that leaves the file as large or makes it smaller is never refused. The + panel's checkmarks run this command; normal playback and Batch… skip excluded steps. - Actions: `ui.action.new {set, name, key?, cmd?, shift?, color?}` adds an action named `name` (spaces trimmed) after the last action of set `set` (0 is the top set), starts recording into it and returns `{set, action}`; Stop Playing/Recording (`ui.action.stop`) ends the recording. `key` 1 to 15 is the function key F1 to F15 that plays the @@ -2887,12 +2986,23 @@ already walks Image Trace. Short agent entry points: (`red`, `yellow`, `green`, `blue` or `violet`; null or `""` for none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. While an action is recording, and for an index past the last set, - an empty name, or a key or color that is not one of these, it is an error and nothing is added. The panel's Create + an empty name, or a key or color that is not one of these, it is an error and nothing is added. So is an action that + would give the set more than 1,000 actions (`the action set file holds more than 1,000 actions`) or a file larger + than 8 MiB (`the action set would be larger than 8 MiB`), and then no recording starts. The panel's Create New Action button and menu's New Action… open the `actionNew` dialog, whose Record runs this. - Actions: `ui.action.record {set?}` starts recording a new action in the chosen set, or the current set when `set` is left out, and returns `{set, name}`. With no sets, it creates Set 1. The action gets the next available `Action N` name in that set. The Actions panel's Begin Recording button runs this command. An invalid set or an - existing recording is an error and leaves the sets and recording unchanged. + existing recording is an error and leaves the sets and recording unchanged, and so is a set where the new action + would give it more than 1,000 actions or an action set file larger than 8 MiB. While a recording (this one or + `ui.action.new`'s) is in progress, a recorded command whose step would give the action more than 10,000 steps, or + its set's action set file more than 8 MiB, is an error with the reason (`the action already has 10,000 steps`, `the + action set would be larger than 8 MiB`): it does not run, and the recording goes on. A batch (`command.batch`) is + such a command too. A command whose step grows past a limit only as it runs, from values it adds to its step, + keeps its edit and result; the status bar names the limit, later recorded commands do not run, and + `ui.action.stop` leaves its step out. A stop that playback reaches + then is not recorded, and the status bar shows the reason. `ui.action.stop` leaves out any recorded steps still past + either limit and shows which one in the status bar. - Actions: `ui.action.options {set, action, name?, key?, cmd?, shift?, color?}` changes the action at `action` in set `set` and keeps its steps: `name` renames it, `key` with `cmd` and `shift` sets its function key, and `color` its Button Mode color, as `ui.action.new` takes them; a param left out keeps what the action has, and @@ -2978,7 +3088,8 @@ already walks Image Trace. Short agent entry points: `ui.actionSet.duplicate {set?: index, action?: index}` copies the action at `action` in set `set` to right after it in the same set, and returns `{set, action}`; without `action` it copies the set with all its actions to right after it, and returns `{set}`. In both cases `set` defaults to the current set. The copy is named after the original with - " copy" added, a copied set gets an id of its own, and the copy is selected. An index past the last set or action is an error. The + " copy" added, a copied set gets an id of its own, and the copy is selected. An index past the last set or action is an error, + and so is a copy that would give its set more than 1,000 actions or a file larger than 8 MiB; then nothing is copied. The panel menu's Duplicate runs this for the selected action, else the current set. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace @@ -3662,7 +3773,7 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: eight generated in code (Art Ribbons, Calligraphic Nibs, Bristle +Brush libraries are read-only sets of brushes: nine generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes with one bristle brush for each tip shape, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, and Dry Media with the Chalk Grain, Graphite Pencil, Wax Crayon and Charcoal Dust art brushes, id `dry-media`), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has @@ -4186,6 +4297,34 @@ the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `Dou `arrowScale`, at least 0.25 pt. `LineArrow` has two arms of the head's weight (the line's weight at 100 % for lines of 0.25 pt or more) at right angles and spreads twice as wide. +Custom arrowheads come from the arrowheads file, `Arrowheads.vectorcraft` next to the preferences (none on the web or +in runs with `VECTORCRAFT_NO_PREFS`). `stroke.arrowheads.edit` (Stroke panel menu › Edit Arrowheads…) opens it, and +first creates it with one example head when it is missing. Each symbol in the file is one head, named after the +symbol and listed after the built-in heads in both arrowhead menus. These rules are VectorCraft's own: a head points +right, the symbol's registration point is its tip, and the art's height is drawn as wide as the built-in heads (four +head weights). The visible fills and the outlined visible strokes of its paths, compound paths and groups, and its +gradient meshes, make the head, with geometry effects applied and clip groups clipped, and the line stops halfway along its length. A head is +one solid shape in the stroke's paint: art at 0 % opacity is left out, and other opacities, blend modes and opacity +masks do not change the shape. The file is read when the app starts and again from disk each time VectorCraft saves it; +`stroke.arrowheads.reload` reads it again. A file larger than 64 MB, or whose document or images unpack to more than 64 MB, is +not read, at most 256 symbols are read, and a symbol that paints nothing, reaches more than 64 head weights from its +tip, has more than 65,536 anchors in its art or in the shapes it paints, has shapes that cannot be combined into one, +or has more than 4,096 anchors once united is listed in `skipped`. `stroke.arrowheads.list` returns each head's `name`, `reach` and `halfWidth`, the `file` +path, the `skipped` symbols and any read `error`. + +`stroke.set {endArrow: {"custom": name}}` puts a custom head on a stroke. The stroke keeps a copy of the head's art, +so the document draws it, saves it and exports it to SVG and PDF the same way without the arrowheads file. +`document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. It +refuses a `path` that fills no area (non-zero rule; parts thinner than a millionth of a head unit count as none) or +has more than 4,096 anchors, more than 4,096 subpaths or a point more than 64 head units from the tip. The help page +[Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes defining custom +arrowheads in a separate arrowheads file. + +```json +{"name":"run_command","arguments":{"command":"stroke.arrowheads.list","params":{}}} +{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"endArrow":{"custom":"Example Arrowhead"}}}} +``` + ## Stroke bounds and clicks Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take @@ -4896,12 +5035,35 @@ is listed as Document › Export Selection…. A shortcut already given to `docu When the other entry is anything else, the command is not listed: `window.learn` is left out, and Window › Learn is the Learn panel's entry. +No two rows of Edit › Keyboard Shortcuts have the same English name. Rows that would have the same group and label +are named this way: + +- A command that an item of the menu bar runs, with no params or with the params all its items give, is listed under + that item's menu path: `window.brushLibrary.other`, `window.graphicStyleLibrary.other`, + `window.swatchLibrary.other` and `window.symbolLibrary.other` are listed as Window › Brush Libraries › Other + Library…, Window › Graphic Style Libraries › Other Library…, Window › Swatch Libraries › Other Library… and + Window › Symbol Libraries › Other Library…. +- A command whose id is another command's id with one more part is listed under that command's label: + `ui.charStyleOptions.resetPanel` and `ui.paraStyleOptions.resetPanel` are listed as Character Style Options › + Reset Panel and Paragraph Style Options › Reset Panel. +- The other rows that still have the same name show their id in parentheses after it. The one row of them that an + item of the menu bar runs keeps the name without its id: File › New… is `file.newDialog` and `file.new` is File › + New… (file.new), and Type › Fit Headline is `text.fitHeadline` and `type.fitHeadline` is Type › Fit Headline + (type.fitHeadline). `file.exportInBackground` and `file.openInBackground` are File › Continue in Background + (file.exportInBackground) and File › Continue in Background (file.openInBackground). + +`shortcuts.list` gives such a row's group in `group` and the command's own label in `label`, so the rows that show +their id have the same `group` and `label` there, and `id` tells them apart. `shortcuts.exportText` writes these +names. When a shortcut is already in use, the message names each row that has it by its whole name, group included, +for every row: a `shortcuts.set` error ends with "is already used by Window › Swatch Libraries › Other Library…". + Each installed filter plug-in's Object › Plug-ins item is its own row, with id `plugin:<id>` (the plug-in's id), its -menu label and no default shortcut, so two filters with the same name have two rows. `shortcuts.set {id: -"plugin:<id>", shortcut}`, `shortcuts.list` and Import Shortcut Set use that id, `shortcuts.exportText` writes one line -for the row under its menu path and label, and the key runs what a click on the item runs, `plugin.dialog {id}`. A -removed plug-in's row goes away and its key runs nothing; its shortcut stays saved and applies again when a plug-in -with the same id is installed. +menu label and no default shortcut, so two filters with the same name have two rows. When the two rows have the same +menu path and label, each shows its id after the name. `shortcuts.set {id: "plugin:<id>", shortcut}`, +`shortcuts.list` and Import Shortcut Set use that id, `shortcuts.exportText` writes one line for the row under its menu +path and label, with its id when the row shows it, and the key runs what a click on the item runs, `plugin.dialog +{id}`. A removed plug-in's row goes away and its key runs nothing; its shortcut stays saved and applies again when a +plug-in with the same id is installed. `shortcuts.exportText {path?}` saves the current set's shortcut list as a UTF-8 text file, as Edit › Keyboard Shortcuts › Export Text… does with the dialog's set as edited so far. The file starts with a "Keyboard Shortcuts" line @@ -5132,7 +5294,11 @@ interface and not the art: a zoom shows the art at the same size at every UI Sca 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 +repeat chooses the tool. The canvas preference `boundingBoxWhileMovingAndDrawing` (off by default) also needs the +desktop app: on, with View › Show Bounding Box, the bounding box and its handles stay visible while a drag moves the +selection, and the Rectangle, Rounded Rectangle, Ellipse, Polygon and Star tools draw the box around the shape being +drawn; off, the box hides during both, except that a Selection tool move with `realTimeDrawing` off still moves the +box. `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` @@ -6867,9 +7033,11 @@ says when CSS can't describe the art exactly (`unsupported`: other shapes, image written as its box, or with `rasterize: true` as `background-image: url(<class>.png)`. `units` is `px` (1 px per point, as in SVG export), `pt`, `mm`, `cm` or `in`; `unnamed: false` leaves out unnamed objects (counted in `skipped`). `css.generate` does the same for the whole document, and `css.export {path?, scope?: selection|all}` -writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). Its -`warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, type written in the -fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web +writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). A left-out +`scope` exports the selection; a `path` or `scope` given as anything but a string, `null` included, is an error, and +nothing is written. `css.generate` and `css.export {scope: "all"}` do not use `ids` or `id`, but a malformed one is still +an error. Its `warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, +type written in the fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web downloads it) and shows the first note in the status bar, with how many there are; resting the pointer on the message lists up to 30 of them, fewer when the list would not fit in the window, with how many were left out. @@ -7552,6 +7720,27 @@ Panel sets it back to Push Out Only. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsokuType":"pushInFirst"}}} ``` +## Bunri-Kinshi + +`text.setFormat {bunriKinshi: bool}` (Paragraph panel menu › Bunri-Kinshi, shown with the East Asian options) turns it +on or off for the paragraphs of the selected type (or `ids`, or the paragraphs a `start` / `end` range touches). On, a +line never breaks inside an inseparable pair: two em dashes (U+2014), two horizontal ellipses (U+2026), two two-dot +leaders (U+2025), or the upper half of a vertical kana repeat mark (U+3033 or U+3034) followed by its lower half +(U+3035), the pairs W3C JLREQ lists for its class cl-08 (https://www.w3.org/TR/jlreq/#cl-08), which are VectorCraft's +set. A chain of em dashes stays together as well. A pair is two neighboring characters, whatever their fonts and +styles; a forced line break between them ends the line as before. Both composers, vertical type, Kinsoku Shori Type, +hyphenation and No Break follow it, and a pair wider than the line stays whole on it. Justification adds no space inside +a pair. The setting works only with a Kinsoku Set: with Kinsoku Set None the paragraph keeps the value without effect, +and the menu item is unavailable. Off by default, for new type and for paragraphs saved before the option. It is saved +with the paragraph (`bunri_kinshi` in `para`) only when on, paragraph styles carry it (Paragraph Style Options › East +Asian Composition Settings), and Reset Panel turns it off. A value that is not true or false is refused. One undo +step. `.ai` editing data does not hold it, and a save reports a paragraph with it on and a pair in it as left out. The help page: +https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"bunriKinshi":true}}} +``` + ## Korean Line Break Anywhere `text.setFormat {koreanLineBreakAnywhere: bool}` (Paragraph panel menu › Korean Line Break Anywhere, shown with the @@ -9097,9 +9286,30 @@ so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`p {"name":"run_command","arguments":{"command":"object.blend.spine.moveAnchor","params":{"id":40,"anchor":1,"x":150,"y":20}}} ``` +`object.blend.setEasing {id?, easingType?, ramp?, colorShift?}` sets one blend's easing and color shift (the +first selected blend without `id`) and returns `{id, easingType, ramp, colorShift, changed}`; omitted values are kept. +`easingType` is `easeIn`, `easeOut`, `easeInOut` or `easeOutIn`, `ramp` is 0 to 100 and `colorShift` -100 to 100. +`easingType` and `ramp` need a blend whose steps follow a spine (a replaced or edited spine, or +`orientation: "path"`), as the Blend panel's Type and Ramp do. A blend made without a spine +(`createSpine: "never"`) takes them once Replace Spine has given it one. A value out of range, a hidden or locked blend and a target that is not a blend are errors, +and a call that changes nothing records no undo step (`changed: false`). With one blend selected that has a spine, +the Selection and Blend tools show the same values as widgets +16 px above and below the spine's halfway point (where the boxes of three or more other visible objects hold that +spot, a widget moves out in 16 px steps, up to 144 px, joined to the halfway point by a line): the upper one sets +`colorShift` and the lower one, shown only when the steps follow the spine as `ramp` needs, sets `ramp`, by one per screen pixel dragged along the spine and by one per arrow key +during the drag (ten with Shift). Each drag is one undo step, and `press_key` Escape or a switch to another tool +during it puts the value back: + +```json +{"name":"run_command","arguments":{"command":"object.blend.setEasing","params":{"id":40,"ramp":35}}} +``` + How blends interpolate: closed shapes start where they twist least unless `starts` picks the anchors; strokes -interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale -and width profiles, while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with +interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale, +width profiles and the pen pressure, tilt, bearing and rotation recorded with the Paintbrush (a stroke without a +recording counts as half pressure, half tilt and 0°, as brushes read it; angles turn the shorter way round; a key's +profiles, recordings and arrowheads turn with its path when the blend starts it at another anchor), while +caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with different stop counts resample to a common set of stops. Groups pair their members in stacking order (members only one group has grow out of the other's centre), compound paths stay compound paths, and type, symbol instances and images interpolate their transforms (type also its colours, stroke weight and size run by run; the words and the @@ -10142,9 +10352,11 @@ change in one undo step, and an image that `ids` names twice changes once. An im as it is, and when none changes the command adds no undo step and returns `changed: 0`. `cleared` counts the pixels made fully clear in the images that changed. The command refuses, and changes nothing, when an image is CMYK, a linked image shows its preview (embed or relink it first), an image would need more than the decode allowance while it is -worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), or every pixel of an -image would be cleared. Remove Background… opens the command's dialog with Preview off; with Preview on, the canvas -shows the result for the values in the dialog, and Cancel puts the image back. While a dialog previews the command, +worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), an image is +grayscale and colorized with a process color (the result has an alpha channel, and only a spot color, black or white +colorizes a grayscale image with transparency, as `paint.setFill` requires), or every pixel of an image would be +cleared. Remove Background… opens the command's dialog with Preview off; with Preview on, the canvas shows the result +for the values in the dialog, and Cancel puts the image back. While a dialog previews the command, the session keeps the decoded pixels of the images it changes (at most 128 MiB, or 32 MiB in the web app), so the next preview step does not decode them again; it releases them when the dialog closes with OK or Cancel. A run that is not a preview step does not keep them. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4cc08b406d..2360cb7bb0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,8 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> +> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -10,6 +12,8 @@ ## Feature submission evidence +M4: Explicitly requested editing data keeps a single artboard larger than its 14,400 pt capped PDF page (#6865). Scores and estimates unchanged. + Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -143,13 +147,13 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, eight generated libraries including Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, nine generated libraries including Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | @@ -190,6 +194,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | M7: Glyphs panel applies feature-valued alternates and unencoded glyphs while keeping Arabic joining (#5514). Scores and estimates unchanged. | | 2026-11-12 | minor | M14: numeric fields keep the caret when a focus click is released more than 1 pt from its press; a tap within 1 pt still selects all (#6659, follow-up to #1326); estimates unchanged. | | 2026-11-12 | minor | M12: Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | diff --git a/docs/shaper.md b/docs/shaper.md index d32659478e..2318edd8a8 100644 --- a/docs/shaper.md +++ b/docs/shaper.md @@ -9,7 +9,7 @@ Scribble over overlapping paths to make an editable Shaper Group: - End the scribble outside the artwork to remove the touched fills and exposed boundary strokes. Shared edges along surviving filled areas remain, closing their contours. Compatible stroke pieces join at the new corners, so the selected stroke’s join style applies there. - Scribble over an unfilled stroke segment to remove that piece. A line passing through a shape is split at the intersections, so its protruding ends can be removed separately. -Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. Outside construction editing, a marquee selects the group or its faces only where it reaches the visible result, and Direct Selection's clicks and marquee select only the anchor points of the visible result. +Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. With the group or one of its faces selected, a Shaper click on an original shape's outline also enters construction editing and selects that shape, even where the outline runs through a merged face ([help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/create-shapes-with-the-shaper-tool.html)). The click counts within 3 screen pixels of the outline, a VectorCraft value. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. Outside construction editing, a marquee selects the group or its faces only where it reaches the visible result, and Direct Selection's clicks and marquee select only the anchor points of the visible result. While the group or one of its faces is selected (Face Selection mode) and the Shaper or Selection tool is active, an arrow widget sits beside the middle of the right side of the original shapes’ bounds. Click it to enter Construction mode: the original shapes are isolated and nothing is selected, and a click with Shaper selects the group’s original shape under the pointer. Click the widget again to leave Construction mode with the group selected. The arrow points right in Face Selection mode and left in Construction mode. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 71b0a9a8fc..80c6c3696c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,8 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> +> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > > Glyphs update (#5514): Alternates for Current Selection applies a variant's OpenType feature to that letter alone (`salt=2`) so an Arabic word stays joined; Entire Font lists unencoded glyphs. A positional variant no supported feature draws can be applied as a glyph choice (#6084). Entire Font uses the selection's font version and variable axes, and the default cell remains available after an unlisted choice even without GSUB alternates. Only Arabic presentation-form characters insert their joining base letter. Scores and estimates unchanged. > @@ -222,7 +224,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522); Live Paint Bucket face drags, including narrow faces and one Undo, regression-tested (#6373) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (eight generated libraries including Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (nine generated libraries including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -491,7 +493,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (eight generated in code including Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (nine generated in code including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -505,7 +507,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4; status-bar X/Y uses the active ruler origin and General units | -| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | +| File formats | 6 | 85% | Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done, as are the Japanese papers in its Media list: JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判, beside the ISO B4/B5 that settings saved before them keep) | 1–2 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | @@ -543,7 +545,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries eight (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries nine (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -592,7 +594,8 @@ since 2026-10-01. - **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. -- **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. +- **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. +- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. An object's or group's Transform effect changes the effects of its fills, strokes and members only through Scale Strokes & Effects, as before. Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged. @@ -600,6 +603,7 @@ since 2026-10-01. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. +- **Roughen at Size 0 keeps the outline:** with Size 0 (relative or absolute), Effect › Distort & Transform › Roughen… moves no point: each segment is split at the Detail spacing (fewer points past the anchor limit) into pieces of equal length on the segment itself, so the path keeps its outline, with Smooth or Corner points alike. A straight segment gets corner points without handles, and a curve gets smooth points whose handles follow it. Before, the points were joined by straight lines (Corner) or a smoothed curve through them (Smooth), which changed curved outlines. Existing documents with a Roughen of Size 0 on a curved path, or with Smooth points, draw differently. This is VectorCraft's own rule; what Roughen does is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `tests::roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly`). Scores and hours unchanged. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. @@ -728,6 +732,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Actions panel: the current set is kept with the UI preferences, so after a restart a recording goes into the set that was current, not into the first set. With no set current, Begin Recording records into the first set and makes it current. - Actions panel: expand an action to show its recorded steps, then clear a step's checkmark to keep it in the action but skip it during playback. The action and set checkmarks include or exclude all their steps; a mixed checkmark includes all. Alt/Option-clicking a step checkmark keeps that step as it was and applies the opposite inclusion state to the other steps in its action. This follows the public [Exclude commands from an action](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html) guide; the scope of the mixed-state and Alt-click behavior is VectorCraft's design. The exclusion survives UI restart, action duplication, and `.vcactions` save/load. Existing version 1 action set files load with every step included. + - Actions panel: an action keeps to the limits that Load Actions… reads while it records and while its steps are excluded: at most 10,000 steps in an action and 8 MiB in its set's file, which are VectorCraft's own rules. Commands add steps to a recording until it stops ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html)); a command whose step would pass a limit does not run, its document stays as it was, and the recording goes on. A command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit; the status bar names the limit, later recorded commands do not run, and Stop Playing/Recording leaves its step out. A stop that playback reaches is not recorded then, and the status bar shows the reason. Clearing a checkmark keeps the step in the action and skips it in playback ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html)); an exclusion that would make the set's file larger than 8 MiB changes no checkmark, and one that makes the file smaller always works. Stop Playing/Recording leaves out any recorded steps still past a limit and names the limit in the status bar. Begin Recording, Duplicate, New Action, renaming an action, Action Options…, Set Options…, New Set… and Load Actions… (`.aia` sets included) also refuse a change that would give a set more than 1,000 actions or a file larger than 8 MiB, and change nothing then. Tests `panels::actions::tests::{recorded_commands_keep_an_action_at_10_000_steps, recorded_commands_keep_an_action_set_file_within_8_mib, a_recorded_command_whose_noted_values_do_not_fit_is_left_out, a_stop_reached_while_recording_a_full_action_is_left_out, stop_leaves_out_recorded_steps_past_the_limits, excluding_steps_keeps_an_action_set_file_within_8_mib, a_recorded_batch_that_edits_another_document_is_left_out_when_it_grows, a_set_renamed_while_it_records_is_measured_again, steps_recorded_after_an_exclusion_past_the_last_step_are_included, duplicate_keeps_action_set_files_within_their_limits, new_action_keeps_an_action_set_file_within_its_limits, renaming_keeps_an_action_set_file_within_8_mib, load_actions_refuses_a_set_that_would_save_larger_than_8_mib}`. - Actions panel: the panel menu's Playback Options… sets how actions play: Accelerated (the default) plays every step at once, Step by Step plays one step at a time with the canvas drawn after each, and Pause For waits the given number of seconds between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html); `ui.action.playbackOptions`). VectorCraft's own rules, which the page does not cover: the pause is a whole number of seconds from 1 to 60, the setting is kept with the UI preferences, a paced playback ends at Stop Playing/Recording or when a step comes due while another document is active, each step of a paced playback is its own undo step, and Batch… plays at full speed. - Actions panel: the panel menu's Insert Stop… opens Record Stop, with a Message box and Allow Continue, and adds a stop to the recording in progress, after the selected command, or at the end of the selected action ([Insert stops in actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-stops-in-actions.html); `ui.action.insertStop`). A playing action waits at the stop and shows the message so the user can do a manual task, and Play Current Selection plays the rest ([Illustrator CS5 help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=459); `ui.action.continue`). With Allow Continue, the message box also has Continue. VectorCraft's own rules, which the pages do not cover: an empty message still waits, Stop Playing/Recording ends the playback and keeps the steps already played, the rest plays on the document active then, a stop waits at every playback speed, at Accelerated the steps between two stops are one undo step, Batch… refuses an action with an included stop, and Delete Selection on a selected step deletes that step, a stop or a command, without asking (`ui.action.deleteStep`). - Actions panel: the panel menu's Insert Menu Item… adds a menu command to the recording in progress, after the selected step, or at the end of the selected action, without running it, and playback runs it as the menu does ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertMenuItem`). The dialog's Find box picks the one item that matches the words typed, and a click on a listed item picks it. VectorCraft's own rules, which the page does not cover: items that open a dialog, the Object › Align items, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern, and the File, Window and Help menu items are not listed, and an action holds at most 10,000 steps and its set file at most 8 MiB, for stops too. @@ -763,16 +768,18 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: an error a `finally` block passes on is still reported where it was thrown when the block's allocations collect garbage under a small heap limit, and a `finally` block that `return`, `break` or `continue` leaves drops the saved location of its error as it leaves, so a collection no longer counts that code as live. VectorCraft's own rule. `crates/js/src/host_tests.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). - Scripts: `#targetengine name` (and `//@targetengine name`) runs the script in a named engine that lasts for the session, and a later script that names it sees the globals and functions earlier ones left ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)); `$.engineName` gives the name ([the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: a script without the line and `#targetengine main` get a new engine each run, the line comes once in the script itself before its code, a session keeps 8 named engines with 64 MiB of heap each, an error or a stop keeps the globals the run set while the documents go back, kept host functions act for the run that calls them, `script.engines` lists the engines and `script.engine.drop` drops one, and the web build has none. `crates/engine/src/tests/script_engines.rs`. - - Scripts: a runtime error is shown at the line that was running ([the help page](https://extendscript.docsforadobe.dev/extendscript-toolkit/debugging-in-the-toolkit/)). VectorCraft's own rules: an error in code an earlier run of a named engine compiled, such as a function it defined, is reported at that code's file, line and column (the earlier run's script, a file it included or a file its `$.evalFile` ran), while the report and its console line keep the calling run's name; a script given as text is named by its run's name, and each engine keeps these places for 8 MiB of source maps, the oldest going first. `crates/engine/src/tests/script_engines.rs`. + - Scripts: a runtime error is shown at the line that was running ([the help page](https://extendscript.docsforadobe.dev/extendscript-toolkit/debugging-in-the-toolkit/)). VectorCraft's own rules: an error in code an earlier run of a named engine compiled, such as a function it defined, is reported at that code's file, line and column (the earlier run's script, a file it included or a file its `$.evalFile` ran), while the report and its console line keep the calling run's name; a script given as text is named by its run's name, and each engine keeps these places for 8 MiB of source maps, the oldest going first. An error in code whose place is gone, or in code that `eval` or `Function` compiled, is reported without a line and column, and the code still runs. `crates/engine/src/tests/script_engines.rs`. - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. + - Errors under `--automation-read-root`: a refused save without a path and `file.revert` (also as an `action.batch` step), `script.listInstalled` and `links.relink {copy}` name the document's own file, the Scripts folder or the document's folder by its file name when it is outside the read roots, and keep the roots and the reason; `links.relink`'s copies name such a file by its file name too. A path the client gives stays whole. `crates/engine/src/tests/save.rs` (`pathless_save_rejection_hides_document_folder`), `crates/engine/src/cmd/fileio/action_batch.rs`, `crates/engine/src/tests/links.rs`, `crates/engine/src/tests/script_folder.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). + - Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, brush art included, leaves out a relative path that leads outside them and a stored brush entry that does not read as a brush, names a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`, `document_json_checks_brush_art_and_relative_paths_against_the_read_roots`). - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`. - - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. + - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session unless a library loaded later without a file shows that id itself, which moves it to the next free `loaded/<file name> (N)`, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. @@ -823,6 +830,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). + - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. The status bar's messages for relinked, replaced, updated and embedded images, broken links, linked files that can't be read or changed on disk, images that can't be decoded, added and unavailable fonts, copied CSS, downloads and exports with their notes name one item in the singular and more in the plural, in English and in each language's plural forms (`counted_status_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). @@ -838,6 +846,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). + - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. @@ -861,7 +870,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Dry Media adds grainy art brushes (Chalk Grain, Graphite Pencil, Wax Crayon, Charcoal Dust). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). - **Dry Media library** ([help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Dry Media holds four art brushes with textured strokes like dry drawing materials, each 100 pt long: Chalk Grain (about 8 pt high), Graphite Pencil (about 2.3 pt), Wax Crayon (about 5.5 pt) and Charcoal Dust (about 7 pt). The brushes, their names, sizes and art are VectorCraft's own: each is filled vector paths from a fixed seed, at most 80 paths and 500 anchors, stretched along the path with Tints colorization, and the chalk and crayon art has even-odd gaps where the paper shows (`brush.libraries`, `brush.addFromLibrary`; `the_dry_media_library_holds_grainy_art_brushes_with_bounded_art`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. @@ -882,6 +891,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Units › East Asian Type (#394, `unitsAsianType`): Points by default; it offers every unit and Q and Ha, a quarter of a millimetre each (0.709 pt: the [Illustrator Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/); `q` and `h` are the field suffixes [InDesign's notation table](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html) lists), and only it lists them: rulers, Document Setup and the other Units preferences keep their ten. The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. The model stays in points; any length field reads `13q`/`13h`. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` (`crates/engine/src/tests/units.rs`). - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except User Interface › Open Documents As Tabs, Units › East Asian Type, Hyphenation › Default Language, Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only; [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - With Performance › Real-time Drawing and Editing off, the bounding box moves with a Selection-tool move drag and the object moves when the button is released ([help](https://web.archive.org/web/20220324053041id_/https://helpx.adobe.com/illustrator/using/real-time-editing.html); `real_time_drawing_off_moves_the_bounding_box_with_a_move_drag`). + - VectorCraft's own preference General › Show Bounding Box While Moving and Drawing (`boundingBoxWhileMovingAndDrawing`, off by default): on, with View › Show Bounding Box, the bounding box and its handles stay visible while a drag moves the selection (a Selection tool move, an Alt-drag copy, a shape tool's move by the center point), measured once when the drag starts and moved with it, and the Rectangle, Rounded Rectangle, Ellipse, Polygon and Star tools draw the box around the shape being drawn. A rotation and a widget drag still hide the box until the release, and the Line Segment tool, the Pen, the Pencil and the Paintbrush draw none (`a_move_drag_keeps_the_bounding_box_where_the_drag_carries_the_selection`, `a_shape_being_drawn_shows_its_bounding_box`, `a_move_drag_measures_the_bounding_box_once`). - Performance › Real-time Drawing and Editing off defers a drag's edits to its release, but a ruler guide dragged out of a ruler or moved still shows as it is dragged, where only its position label showed (#4646; `a_guide_dragged_out_of_a_ruler_shows_while_dragged`, `real_time_drawing_off_still_shows_a_dragged_guide`). - With Real-time Drawing and Editing off, a dragged selection whose outline points and evaluated objects come to more than 100,000 shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_group_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_repeat_with_too_many_objects`). - With Real-time Drawing and Editing off, a move or handle drag of a group, blend, envelope, repeat or intertwine draws the outline of each of its objects instead of one union of them, as VectorCraft's own rule (`real_time_drawing_off_outlines_a_dragged_group_member_by_member`, `real_time_drawing_off_outlines_a_resized_group_member_by_member`, `real_time_drawing_off_outlines_a_dragged_blend_step_by_step`, `real_time_drawing_off_outlines_a_dragged_envelope_object_by_object`). A group with a Pathfinder effect draws the area the effect makes, a gradient mesh draws its mesh lines, and a symbol instance draws its bounds (`real_time_drawing_off_outlines_a_dragged_pathfinder_group_by_its_area`, `real_time_drawing_off_outlines_each_mesh_of_a_dragged_repeat_once`, `real_time_drawing_off_outlines_a_dragged_symbol_instance_by_its_bounds`). @@ -901,6 +911,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`). - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. + - While Tab or Shift+Tab hides the panels, the Window menu checks no panel, and choosing a panel there, or pressing its function key, shows the dock with that panel in its earlier place instead of closing it; the toolbar and the Control bar stay as they are. In Presentation Mode no panel is checked, and `window.panel` fails and changes nothing. A tool double-click that opens the Gradient or Magic Wand panel and a Character field shortcut also show the hidden dock (`window_panel_from_tab_hidden_dock_reveals_named_front_tab`, `window_panel_from_shift_tab_hidden_floating_window_reveals_it`, `function_key_from_tab_hidden_dock_reveals_named_group`, `presentation_panel_command_reports_unavailable_without_mutating_layout`). - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - On Windows and Linux, F2, F3 and F4 run Cut, Copy and Paste alongside Ctrl+X, Ctrl+C and Ctrl+V, and in the web build they do so everywhere except macOS. The Edit menu keeps the Ctrl shortcuts, and Keyboard Shortcuts lists both keys of each row. On macOS only Cmd+X, Cmd+C and Cmd+V are the defaults. F2, F3 and F4 do not run these commands in a focused text field or while the Type tool edits text. A saved shortcut set that assigns one of these keys to another command keeps that assignment. Public reference: [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html). - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). @@ -928,12 +939,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it, and `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in. Only the values `app`, `system` and `desktop` take precedence (case-sensitive, surrounding spaces ignored); any other value, such as `unknown`, is ignored and does not override System Title Bar. - Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do. + - No two rows of Edit › Keyboard Shortcuts have the same English name (VectorCraft's own rule). A command that an item of the menu bar runs is listed under that item's menu path (Window › Swatch Libraries › Other Library…), a command whose id extends another command's id is listed under that command's label (Character Style Options › Reset Panel), and the rows that still share a name show their id after it, except the one row an item of the menu bar runs (File › New… and File › New… (file.new)). The search finds a row by the id it shows, and a message about a shortcut already in use names the other row with its group. Test `shortcut_editor::tests::no_two_entries_have_the_same_name`. - A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559). - On Windows a maximized window stays inside the work area with the taskbar on any edge (#3242, #3723). - On Windows a maximized window stops one pixel short of each monitor edge where an auto-hidden taskbar sits. - **Tools:** - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing, and with the Shaper or Selection tool a click on the arrow widget beside a Shaper Group's original shapes switches the group between Face Selection and Construction mode. Native save/reopen, face recoloring, Release and Expand are covered. Finer gesture heuristics remain. In Construction mode, a Selection-tool drag that drops an original shape wholly outside the bounds of the group's original shapes takes it out of the group in the move's undo step (Remove from Shaper Group, `shaper.releaseSource`): the shape becomes a path above the group and stays selected, and a group left with no shapes is removed. Engine tests drag a shape out of a two-shape group and back across the bounds, take the last shape out, undo, and save the result. + With a Shaper Group or one of its faces selected, a Shaper click within 3 screen pixels of an original shape's outline enters Construction mode with that shape selected, even where the outline runs through a merged face (`shaper.select`'s `tolerance`; [help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/create-shapes-with-the-shaper-tool.html)). Test: `tests::shaper::shaper_click_on_an_original_outline_enters_construction_mode`. - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). The search for the segment of a path nearest a point, which Direct Selection runs to pick the cursor over a segment, skips each segment whose anchors and handles lie in a box farther from the point than a segment already measured, starting with a segment at the anchor nearest the point. The segment found is the same as before. Over a segment of an open path of 200,000 anchors, the cursor takes 51 ms instead of 59 ms in a release build (`cargo run --release -p vectorcraft-engine --example anchor_bench`). A Direct Selection marquee over part of a segment, with neither of its anchors inside, selects that segment as a click on it does (Shift toggles it), as [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describes (#4654). @@ -956,6 +969,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. + `css.export` refuses a `scope` or `path` that is not a string (`5`, `true`, `["all"]`, `null`) before writing anything, instead of exporting the selection or returning the CSS as data; a left-out `scope` still exports the selection, and a left-out `path` still returns the CSS. `css.generate` and `css.export {scope: "all"}` refuse a malformed `ids` or `id`, which they do not use, as the selection's export does. 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. @@ -1018,6 +1032,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). A link toggle behind Corner Radius (the `singleCornerRadius` preference) switches the panels to a field for each corner of a live rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With Direct Selection a click on a widget (no drag) selects its corner, as a click on the corner's anchor does, so the next drag on it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229). A round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512). View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). + - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. @@ -1216,8 +1231,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. + The Paragraph panel menu's Bunri-Kinshi (`text.setFormat {bunriKinshi}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) keeps inseparable pairs on one line in each paragraph that has it: two em dashes, two ellipses, two two-dot leaders and the two halves of a vertical kana repeat mark (the pairs of W3C JLREQ's class cl-08, VectorCraft's set). Both composers and vertical type follow it, and justification adds no space inside a pair. It works with a Kinsoku Set only, is off by default and is saved only when on; editing the set of pairs is not available. The Paragraph panel menu's Korean Line Break Anywhere (`text.setFormat {koreanLineBreakAnywhere}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) sets for each paragraph whether a line may break between any two Hangul syllables. When it is off, a word that contains Hangul breaks only at its spaces and after punctuation, or where the word is wider than the line, and takes no hyphen. New type has it off; paragraphs saved before the option read as on, so their lines do not move. - The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. + The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Bunri-Kinshi, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. @@ -1254,6 +1270,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Linked opacity masks follow geometry effects, the Free Transform distort and envelopes:** when masked art moves, a linked opacity mask moves with it and an unlinked one stays in place ([Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html)). Applying that rule when art is reshaped, as below, is VectorCraft's own rule. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, with the type and live objects in a mask's art outlined first. Each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too, and with Scale Strokes & Effects on, the mask's strokes scale as a path's mask's do. With Transform Objects off, a member's mask takes the effect's stroke and pattern changes as the member does. The linked masks in the art of a symbol instance in perspective are projected with that art. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object, in a group too, in the same undo step. An envelope bends only a linked mask. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, the mask would get fewer Transform copies, the mask's art holds an image or symbol instance and the effect is not Transform, a Warp effect would bend an image that has a linked mask, or the effect is a live-effect plug-in that paints its pieces), the art and its masks keep their shape under that effect. The copies of a masked path count the art of its mask against the budget. Existing documents draw differently in four cases: a geometry effect on a group whose members have linked masks moves those masks; an envelope no longer bends an unlinked mask; art with linked masks keeps its shape under an effect that its masks can't take; and a Transform effect can make fewer copies of a masked path near the budget (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::a_compound_paths_and_a_layers_members_masks_take_the_effects`, `group::tests::a_groups_effects_reshape_a_live_object_in_a_members_mask`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_groups_transform_moves_a_symbol_instance_members_linked_mask`, `group::tests::a_groups_transform_moves_a_linked_mask_whose_art_is_an_image`, `group::tests::art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp`, `group::tests::art_with_a_masked_image_keeps_its_shape_under_a_warp`, `group::tests::a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike`, `group::tests::a_transform_of_strokes_only_scales_a_members_mask_strokes`, `group::tests::perspective_projects_the_masks_in_a_symbols_art`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`, `group::tests::transform_copies_count_the_art_of_their_masks`, `group::tests::the_mask_of_a_masks_art_copied_whole_counts_against_the_budget`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `xform::distort_command_warps_the_linked_mask_of_a_path_in_a_group`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`, `plugins::a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is`). Scores and hours unchanged. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged. +- **Image Trace, gray photos:** Color and Grayscale smooth a gray photo within its tones before choosing its colors, as they do a color photo. A picture with at least 95% of its opaque pixels within 8 levels across their channels counts as continuous tone when 8 colors leave an error of more than 6 levels and more than 0.065 of the error of one color, instead of 0.1, unless 16 colors leave at most a fifth of the error of 8 (VectorCraft's own values). That exception keeps gray flat art of up to 16 clean levels on its old route; anti-aliased or noisy gray flat art can now count as continuous tone too. Most gray photos the change reaches, and very noisy gray scans of flat art, trace with fewer specks and fewer staircase edges. Some 16 Colors and High Fidelity Photo palettes of gray photos grow toward the requested count, with more anchors and regions. Some gray illustrations and sketches get more anchors and staircase edges at 16 Colors and High Fidelity Photo, and some 6 Colors traces get more staircase edges. Tests: `tests::a_gray_photo_is_continuous_tone_as_its_color_original_is`, `tests::a_gray_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. +- **Image Trace, tinted photos:** Color and Grayscale smooth a sepia, single-hue or duotone photo within its tones before choosing its colors, as they do a gray photo. A picture with at least 95% of its opaque pixels within 6 levels of its tone curve counts as continuous tone under the same 0.065 rule as a neutral picture. The tone curve runs through the median color of each 8-level stretch along the direction in which the picture's colors spread the most, and a pixel on it must also lie within 6 levels of the plane the colors spread along most, so art in several unrelated colors is not counted (VectorCraft's own rule and values). The neutral rule's exception for 16 colors leaving at most a fifth of the error of 8 applies to it too. Clean tinted bands of 2, 3 and 9 levels and tinted scans of flat art with up to 8 levels of noise stay on the flat-art route. On the synthetic tinted photos the 16 Colors trace keeps 12 to 15 colors instead of 6 to 13, with 525 to 708 anchors instead of up to 1,402. A duotone with strong noise on each channel can still miss the curve and stay unsmoothed. Tests: `tests::a_tinted_photo_is_continuous_tone_as_a_gray_photo_is`, `tests::small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone`, `tests::tinted_flat_art_stays_flat_art`, `tests::pictures_without_tones_are_not_continuous_tone`, `tests::a_tinted_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. - **Image Trace, Document Library colors:** with Document Swatches, traced fills and strokes use the displayed colors of process, global, spot and tint swatches without links, so editing a swatch leaves the trace unchanged; swatch-library colors are also unlinked; code-drawn tests in `crates/engine`. - **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`. @@ -1283,6 +1301,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. +- **Image Trace, Flat Logo flat-art check with named colors:** with `logoColors` named, whether the image is flat art is judged by its own colors, as many as are named, with white, or as the Colors setting gives, whichever is more. Named colors 20 levels off a flat badge's colors still trace it, a hatch on a light ground gives the same result with close and far named colors, a gradient's refusal score does not depend on how far the named colors are from it, and areas of colors that were not named are traced in the named colors instead of being refused; code-drawn tests in `crates/trace`. - **Image Trace, Keep Colors:** `"keepColors": ["#rrggbb", …]` traces only the palette colors near a listed color (Abutting: each with the paths the trace of every color gives it; Overlapping: not reaching under the colors left out), in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; an image that isn't turned or skewed, isn't CMYK and can be read at its recorded size is cut from its own pixels when the crop edges fall on its pixel grid, copied as they are without resampling; any other image is drawn, #5534; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). @@ -1329,6 +1348,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`. Unchecking Show Recent Colors in the Color panel's menu hides its Recent Colors row, and checking it shows the row again ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft the item is checked by default and kept between sessions, applied colors still join the list while the row is hidden, Hide Options hides the row either way, and the Swatches panel keeps its row (`ui.colorPanel {recentColors}`). Tests: `show_recent_colors_hides_and_shows_the_row` in `crates/ui-egui/src/panels/color/tests.rs`. + A Recent Colors chip in the Color or Swatches panel drags into the Swatches panel's list to become a swatch of its color, in its color model, as one undo step ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft a chip dropped on a color group or one of its swatches joins that group, anywhere else in the list it joins the ungrouped swatches at the end, a color that is already a swatch is added again under another name, and dragging a chip does not paint the selected art (`swatch.new`). Tests: `a_recent_color_dragged_onto_the_list_becomes_a_swatch` and `a_recent_color_dragged_from_the_color_panel_becomes_a_swatch` in `crates/ui-egui/src/panels/swatches/tests.rs`. A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them. View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value. The Separations Preview panel's Gamut Check button reports one color outside the CMYK gamut in the singular and more in the plural, in English and in each language's plural forms (`gamut_check_status_takes_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). @@ -1523,6 +1543,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `.ai` and EPS editing-data import keeps each path's Attributes panel note (`%AI3_Note:`, `%%Note:`, more lines in `%AI5_NoteMore:`, up to 1,024 characters) and Show Center (`1 Ap`); a note or Show Center applies to every path after it until the next one, and a note before a group or compound path is not theirs (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`). `.ai` and EPS editing-data import reads object tags (`/identifier (string) XT`) on the object before them, whatever kind of object it is: `AdobeURL` becomes the object's URL, and every other tag is kept in the object's data in order, up to 64 per object; a tag longer than 64 KiB, and tags in a pattern's or brush's art, are ignored. A tag on art that shows no longer stops the editing data from being read, and brush art that holds tags is read (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`). A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). + Explicitly requested editing data keeps the full single artboard when its PDF page is capped at 14,400 pt (#6865; `crates/eps/src/import/tests_native.rs`). Scores and estimates unchanged. A `.ai`, or a PDF saved with Illustrator's editing data, whose PDF part can't be read (a stream the import refuses) opens from its editing data, with the PDF part's error as its first warning; File › Place places its first artboard the same way, and Paste of it as PDF data with `editingData: true` takes every object (part of #4366; `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - JPEG XL (`.jxl`) images open and place, linked or embedded, as their first frame, decoded with jxl-oxide and stored as PNG: 16-bit and floating-point samples become 8 bits, alpha is kept, and an image past the pixel allowance is refused from its header (part of #2306; `crates/engine/src/cmd/fileio/tests_jxlread.rs`). @@ -1712,6 +1733,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend, mesh and envelope points by number:** `object.blend.spine.moveAnchor`, `reshapeSegment` and `removeAnchor`, `object.mesh.setPointColor`, `movePoint` and `deletePoint`, and `object.envelope.setMeshPoint` refuse an `anchor`, `segment` or `index` that is not a whole number, and `object.envelope.setMeshPoint` refuses a negative `index`. Specified Steps (`object.blend.make`, `object.blend.options`) and the rows and columns of `object.mesh.create`, `object.envelope.makeWithMesh` and `resetWithMesh` must be whole numbers too. +- **Blend widgets on the canvas:** with one blend selected that has a spine, the Selection and Blend tools show two widgets 16 px above and below the spine's halfway point (further out, joined to that point by a line, where the boxes of three or more other objects crowd the spot), as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) describes an upper widget for Appearance and a lower one for Easing. The upper one sets Shift and the lower one Ramp, keeping the Type, and the lower one shows only when the steps follow that spine; a blend made without a spine shows neither. A drag along the spine changes the value by one per screen pixel from its value at the press, the arrow keys during the drag by one (Shift: ten), and Escape or a tool switch cancels it. A drag is one undo step, or none when the value ends where it started. The placement, the drag and the keys are VectorCraft's own rules. `object.blend.setEasing` sets the same values for agents. Evidence: `blendwidget::tests`, `tests::blendopts::{set_easing_edits_one_blend_and_refuses_what_it_cannot_set, dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none}`, `shortcuts::tests::shift_arrows_step_a_pressed_blend_widget_by_ten`. + - **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`. - **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were. @@ -2024,7 +2047,7 @@ A VectorCraft addition (it replaces a script some users ran), so no area score m ## Image Tools add-on: Remove Background (2026-11-11) -A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (<https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance, results with no pixel left and grayscale images colorized with a process color are refused (the result has an alpha channel, and a process color can't colorize a grayscale image with one: [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"). The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (<https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). ## Color Effects add-on: Find Nearest Swatch (2026-11-01) @@ -2094,6 +2117,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | Glyphs review (#6084): Entire Font uses the selection's version and variable axes for previews and alternate applicability. The default cell can reset an unlisted glyph choice when GSUB lists no alternates. Encoded Latin and other ordinary characters insert unchanged even if their glyph also has Arabic positional provenance; Arabic presentation forms retain base-letter insertion. Remove a redundant charmap scan from unlisted-glyph checks. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. | | 2026-11-07 | minor | Type core: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to the selected letter alone, so an Arabic word stays joined (`salt=2`; the style commands take `tag=N`), and Show › Entire Font lists the glyphs no character maps (#5514; [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)); evidence added; scores unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index c6a2d61a74..39f2713ac5 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -34,7 +34,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table. A double-click applies the OpenType feature that draws a variant to that character alone, or uses a glyph choice when no supported feature draws it; see the Glyphs panel row below for Entire Font and the joining limits. The glyph-cell pop-up of alternates is not there yet | | Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, applies one no feature reaches (`A.salt` in a font with no `salt`) to the selected character as a glyph choice (it may not join), and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | -| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | +| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972); wherever the font menu opens, its filters keep the same four rows, and it opens at least wide enough for them and for a family name up to 220 points wide with its sample (up to 60% of the window), each sample filling its row's height (a VectorCraft layout). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | | Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 81cfe1e0e1..423dd1af28 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -374,7 +374,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | -| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`; `panels::learn::tests` | +| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`, `learn.locateTool`; the mark's look and 3-second lifetime are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | | Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests | @@ -410,6 +410,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Select › Start Global Edit / Stop Global Edit; Properties › Quick Actions with the Global Edit Options popover (Match: Appearance, Size; On Artboards: All Artboards or a range, Include Objects On Canvas; Result(s)); Escape stops it; the matched objects are highlighted in their layer colour while it runs; a Shift-click on a highlighted match leaves it out | partial | #3521, `select.globalEdit` / `select.globalEditOptions` / `select.globalEditExclude`, `crates/engine/src/tests/globaledit.rs`; paths, compound paths, symbol instances and groups of them (similar groups, #4939: edits inside one group reach the same object in the others; [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); Shift-click to leave a match out ([public source](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)); the Control bar's Global Edit Options dropdown opens the same options; rotated copies and highlighting matches before the start remain ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)) | | Contextual task bar | done | | | Control bar with Transform and Stroke popovers | done | | +| Stroke panel: custom arrowheads after the built-in ones in both arrowhead menus, from the arrowheads file that the panel menu's Edit Arrowheads… opens; saving the file reloads the menus, and a stroke keeps a copy of its custom head's art | done | [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) (Customize arrowheads); the file's layout (one symbol per head, pointing right, tip at the registration point, height drawn as wide as the built-in heads) is VectorCraft's own; `stroke.arrowheads.edit`, `stroke.arrowheads.list`, `stroke.arrowheads.reload`, `stroke.set` `{custom: name}`; engine `tests::arrowheads`, `stroke::tests_arrows::custom_heads_draw_their_own_art_at_the_tip` | | Control bar: Paragraph: link and Align left/center/right while type is selected (#5372) | done | After the Character controls, as the public documentation has the Control panel format paragraphs ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html), [Align text](https://helpx.adobe.com/illustrator/using/align-text.html)); the link opens the Paragraph panel in a popover, as Character: does with the Character panel. The bar trims from its right end: X/Y/W/H, Distribute, Align, area type vertical alignment, then these buttons, then the link. The three-button set is our choice for room; all seven alignments are in the popover. `panels/paragraph/tests.rs` headless tests (`the_control_bar_shows_the_paragraph_alignment_of_the_selected_type`, `the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right`) | | Pathfinder shape results keep their source names and SVG IDs | done | #1293; Minus Front keeps the back name, Minus Back the front name; Unite/Intersect/Exclude use the backmost named input; compound output carries it once; geometry and paint unchanged | | Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) |