diff --git a/apps/vectorcraft/src/mac_cursor.rs b/apps/vectorcraft/src/mac_cursor.rs index 85fea6c78a..69685a35b5 100644 --- a/apps/vectorcraft/src/mac_cursor.rs +++ b/apps/vectorcraft/src/mac_cursor.rs @@ -35,7 +35,6 @@ trait Backend { fn suspend(&mut self, window: &Self::Window); fn restore(&mut self, window: &Self::Window); fn build(&mut self, request: &Request) -> Option; - fn current(&self) -> Option; fn set(&mut self, cursor: &Self::Cursor); } @@ -73,9 +72,10 @@ impl State { && *key == request.key && *owned == window { - if self.backend.current().as_ref() != Some(cursor) { - self.backend.set(cursor); - } + // AppKit can show the system arrow when the pointer moves while + // `NSCursor.currentCursor` (the app's last set cursor) still reads as ours, so a + // comparison would keep the arrow. Setting the cursor again costs nothing. + self.backend.set(cursor); return; } self.release(); @@ -135,9 +135,6 @@ impl Backend for AppKit { fn build(&mut self, request: &Request) -> Option { build(&request.bitmap) } - fn current(&self) -> Option { - Some(NSCursor::currentCursor()) - } fn set(&mut self, cursor: &Self::Cursor) { cursor.set(); } diff --git a/apps/vectorcraft/src/mac_cursor/tests.rs b/apps/vectorcraft/src/mac_cursor/tests.rs index 648492c4b2..ad13a11336 100644 --- a/apps/vectorcraft/src/mac_cursor/tests.rs +++ b/apps/vectorcraft/src/mac_cursor/tests.rs @@ -6,7 +6,6 @@ use std::cell::Cell; struct Fake { window: Option, suspended: Vec, - current: Option, sets: usize, builds: usize, restores: Vec, @@ -27,17 +26,12 @@ impl Backend for Fake { fn restore(&mut self, w: &u8) { self.suspended.retain(|v| v != w); self.restores.push(*w); - self.current = None; } fn build(&mut self, r: &Request) -> Option { self.builds += 1; (!self.fail).then_some(r.key) } - fn current(&self) -> Option { - self.current - } - fn set(&mut self, cursor: &Key) { - self.current = Some(*cursor); + fn set(&mut self, _: &Key) { self.sets += 1; } } @@ -66,19 +60,16 @@ fn deliver(state: &mut State, request: Option) { } #[test] -fn mac_cursor_ownership_deduplicates_reasserts_and_restores() { +fn mac_cursor_ownership_reasserts_every_pass_and_restores() { let req = request(1.0); let mut state = fake_state(); deliver(&mut state, Some(req.clone())); assert_eq!(state.backend.suspended, [1]); assert_eq!((state.backend.builds, state.backend.sets), (1, 1)); + // The pointer moves and AppKit shows the system arrow, but the app's current cursor + // still reads as ours. The next pass sets it again without rebuilding or re-suspending. deliver(&mut state, Some(req.clone())); - assert_eq!(state.backend.sets, 1); - // Simulate a platform fallback replacing the cursor after finalization. The next pass - // reasserts; this proves the protocol, not AppKit timing or absence of transient flicker. - state.backend.current = None; - deliver(&mut state, Some(req.clone())); - assert_eq!(state.backend.sets, 2); + assert_eq!((state.backend.builds, state.backend.sets, state.backend.suspended.len()), (1, 2, 1)); deliver(&mut state, None); assert_eq!(state.backend.restores, [1]); deliver(&mut state, Some(req.clone())); diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 448b2c2ba0..f25615c589 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -370,11 +370,14 @@ pub struct ArtBrush { pub scale: ArtScale, /// Width in percent. pub width: f64, - /// How the width varies along a stroke: `None` (Fixed) always `width`; Pressure goes from - /// `width` at the lightest pen pressure to [`Self::width_max`] at the heaviest. + /// How the width varies along a stroke: `None` (Fixed) always `width`; a mode that follows the + /// pen goes from `width` to [`Self::width_max`] over the ranges [`Variation`] gives: Pressure + /// from the lightest pressure to the heaviest, Tilt from the pen lying flat to upright, Bearing + /// and Rotation from −180° to 180° (half way at 0°). #[serde(skip_serializing_if = "Option::is_none")] pub width_mode: Option, - /// The width at the heaviest pen pressure (%), for Pressure (`width` without one). + /// The second width (%) of a mode that follows the pen: at the heaviest pressure, upright, or + /// at 180° (`width` without one). #[serde(skip_serializing_if = "Option::is_none")] pub width_max: Option, pub flip_along: bool, @@ -388,11 +391,11 @@ impl ArtBrush { /// The range of the width (%). pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0); - /// The widths (%) at the lightest and the heaviest pen pressure: `width` for both unless the - /// width follows the pressure. + /// The widths (%) where the pen reads 0 and 1 for the width's mode (the lightest and the + /// heaviest pressure, for Pressure): `width` for both unless the width follows the pen. pub fn width_range(&self) -> (f64, f64) { match self.width_mode { - Some(Variation::Pressure) => (self.width, self.width_max.unwrap_or(self.width)), + Some(m) if m.follows_pen() => (self.width, self.width_max.unwrap_or(self.width)), _ => (self.width, self.width), } } @@ -713,8 +716,8 @@ impl Brush { } BrushKind::Art(a) => { a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0); - // The width follows the pen's pressure or stays fixed. - if a.width_mode != Some(Variation::Pressure) { + // The width follows the pen (its pressure, tilt, bearing or rotation) or stays fixed. + if !a.width_mode.is_some_and(Variation::follows_pen) { a.width_mode = None; } a.width_max = a.width_max.map(|w| within(w, ArtBrush::WIDTH_RANGE, a.width)); @@ -806,7 +809,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok let mut out = match &brush.kind { BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name), BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name), - BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()), + BrushKind::Art(a) => warp::art(a, bp, w, stroke), BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke, &brush.name), BrushKind::Pattern(p) => warp::pattern(p, bp, w), }; diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 11c83d97bc..affd2fa6b7 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1371,6 +1371,62 @@ fn art_brush_width_follows_the_pressure() { assert!(plain.get("width_mode").is_none() && plain.get("width_max").is_none(), "{plain}"); } +/// Art Brush Options: Width on Tilt goes from Width with the pen lying flat to the second width +/// upright, and on Bearing or Rotation from Width at −180° to the second width at 180°, along the +/// stroke as the pen recorded it; each mode reads only its own input. +#[test] +fn art_brush_width_follows_tilt_bearing_and_rotation() { + let brush = |mode: Variation| { + let mut b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch); + if let BrushKind::Art(a) = &mut b.kind { + (a.width, a.width_mode, a.width_max) = (50.0, Some(mode), Some(300.0)); + } + b + }; + let bp = line(0.0, 0.0, 100.0, 0.0); + let draw = |b: &Brush, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st); + // Tilt, upright at the start (300 %: 6 pt either side of 4 pt art) to flat at the end (50 %: 1 pt). + let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) }; + let out = draw(&brush(Variation::Tilt), &upright_to_flat); + assert!(near(half_width(&out, 0.0, 1.0), 6.0, 0.1), "{}", half_width(&out, 0.0, 1.0)); + assert!(near(half_width(&out, 99.0, 100.0), 1.0, 0.1), "{}", half_width(&out, 99.0, 100.0)); + assert!(near(half_width(&out, 49.0, 51.0), 3.5, 0.15), "{}", half_width(&out, 49.0, 51.0)); + // Bearing and Rotation: half way at 0°, a quarter of the way at −90°, three quarters at 90°. + for (mode, angle, half) in [ + (Variation::Bearing, 0.0, 3.5), + (Variation::Bearing, -90.0, 2.25), + (Variation::Bearing, 90.0, 4.75), + (Variation::Rotation, -90.0, 2.25), + (Variation::Rotation, 90.0, 4.75), + ] { + let st = if mode == Variation::Bearing { posed(Some(0.5), Some(angle), None) } else { posed(None, None, Some(angle)) }; + let h = bounds(&draw(&brush(mode), &st)).height(); + assert!(near(h, 2.0 * half, 1e-6), "{mode:?} {angle}: {h}"); + } + // Each mode reads its own input: without it (the pressure, or another of the pen's values + // recorded instead), the art is half way between the two widths. + let others = [ + (Variation::Tilt, posed(None, Some(90.0), Some(90.0))), + (Variation::Tilt, pressed(vec![(0.0, 0.0), (1.0, 1.0)])), + (Variation::Bearing, posed(Some(0.0), None, Some(90.0))), + (Variation::Rotation, posed(Some(0.0), Some(90.0), None)), + (Variation::Pressure, posed(Some(0.0), Some(90.0), Some(90.0))), + ]; + for (mode, st) in others { + let h = bounds(&draw(&brush(mode), &st)).height(); + assert!(near(h, 7.0, 1e-6), "{mode:?}: {h}"); + } + // The pen's modes are kept when a definition is read, and Random is read as Fixed. + let lib = parse_library(&serde_json::json!([ + {"name": "t", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "tilt", "width_max": 300.0}, + {"name": "b", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "bearing", "width_max": 300.0}, + {"name": "r", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "rotation", "width_max": 300.0}, + {"name": "x", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "random", "width_max": 300.0}, + ])); + let modes: Vec<_> = lib.iter().map(|b| if let BrushKind::Art(a) = &b.kind { a.width_mode } else { None }).collect(); + assert_eq!(modes, [Some(Variation::Tilt), Some(Variation::Bearing), Some(Variation::Rotation), None]); +} + /// Definitions from files and commands are kept within their ranges. #[test] fn sanitize_keeps_values_within_their_ranges() { diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index b34a1cf31e..c72e1c33f8 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use kurbo::ParamCurveNearest; -use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile, StrokeLayer}; +use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2}; use crate::track::{Rng, Track, normal, seed_of, tracks}; @@ -503,9 +503,10 @@ fn art_on_run( bend_widened(t, art, b, along, cross, widen) } -/// An art brush's art along `bp`. With a width that follows the pen's pressure, the art is as -/// wide as the pressure recorded at each point of the stroke gives (half way without a recording). -pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>) -> Vec { +/// An art brush's art along `bp`. With a width that follows the pen (its pressure, tilt, bearing +/// or rotation), the art is as wide as what `stroke` recorded at each point gives (half way +/// without a recording). +pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, stroke: &StrokeLayer) -> Vec { let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] }; let (light, heavy) = a.width_range(); let width = light.max(heavy); @@ -522,7 +523,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre } // A run past the start of a closed track wraps round to it. let at = if s > l { s - l } else { s }; - let p = pressure.map_or(PressureProfile::MID, |p| p.at(at / l)).clamp(0.0, 1.0); + let p = a.width_mode.and_then(|m| PenAt::of(stroke, at / l).control(m)).unwrap_or(0.5); (light + (heavy - light) * p) / width }; let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] }; 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/blend.rs b/crates/doc/src/blend.rs index 2f8b40014e..ebd4811959 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; @@ -255,9 +256,101 @@ fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> O 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). +/// 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); + at.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + 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 +360,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 @@ -1755,6 +1852,63 @@ 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 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/text/char_style.rs b/crates/doc/src/text/char_style.rs index 37c76c5e3a..fcaa78e494 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -57,7 +57,9 @@ pub struct CharStyle { #[serde(default)] pub all_caps: bool, /// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns - /// a feature on, `"-liga"` off. + /// a feature on, `"-liga"` off, and `"salt=2"` turns a feature with alternates on at the font's + /// alternate number 2 (the value the shaper gets; a plain `"salt"` is value 1). Runs that + /// differ only in this list are shaped together, each with its own features. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub features: Vec, /// Character style (Character Styles panel) these attributes come from; None = Normal. diff --git a/crates/engine/src/cmd/addons/cutting.rs b/crates/engine/src/cmd/addons/cutting.rs index 8412af9a68..2734ca10b3 100644 --- a/crates/engine/src/cmd/addons/cutting.rs +++ b/crates/engine/src/cmd/addons/cutting.rs @@ -1,11 +1,16 @@ //! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil //! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together -//! ([`stencil`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialog are the UI's. +//! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art +//! for vinyl cutting ([`weed`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are +//! the UI's. mod stencil; +mod weed; use super::super::CommandSpec; pub fn specs() -> Vec { - stencil::specs() + let mut v = stencil::specs(); + v.extend(weed::specs()); + v } diff --git a/crates/engine/src/cmd/addons/cutting/stencil.rs b/crates/engine/src/cmd/addons/cutting/stencil.rs index a8b4cfe591..dc415ce73d 100644 --- a/crates/engine/src/cmd/addons/cutting/stencil.rs +++ b/crates/engine/src/cmd/addons/cutting/stencil.rs @@ -89,7 +89,12 @@ fn bridges(s: &mut Session, p: &Value) -> Result { }; let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect(); let spec = BridgeSpec { width, count: count as usize, direction }; - let bridged = stencil_bridges(&refs, spec).map_err(|e| EngineError::Other(e.to_string()))?; + let bridged = stencil_bridges(&refs, spec).map_err(|e| match e { + vectorcraft_pathops::PathOpsError::TooComplex => { + EngineError::Other("Stencil Bridges: the selected art is too large or too detailed to bridge".into()) + } + e => EngineError::Other(e.to_string()), + })?; (bridged, style_of(&top), roots) }; let summary = |id: Option, original: Option| { diff --git a/crates/engine/src/cmd/addons/cutting/stencil/tests.rs b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs index ad072ca13d..b21f0fef5c 100644 --- a/crates/engine/src/cmd/addons/cutting/stencil/tests.rs +++ b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs @@ -277,6 +277,34 @@ fn bad_params_are_refused() { assert!(s.execute("addon.cutting.bridges", &json!({"width": 4})).is_ok(), "good params run"); } +#[test] +fn art_too_detailed_to_bridge_is_refused_with_a_message_that_says_so() { + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("dense", |d, sel| { + // More anchors than Stencil Bridges measures. + let mut zigzag = kurbo::BezPath::new(); + zigzag.move_to((100.0, 100.0)); + for i in 1..=vectorcraft_pathops::addons::stencil::MAX_STENCIL_SEGMENTS { + zigzag.line_to((100.0 + i as f64 * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + zigzag.close_path(); + let huge = vectorcraft_geom::PathData::from_bezpath(&zigzag); + let n = vectorcraft_doc::Node::path(d.alloc_id(), huge, vectorcraft_doc::Appearance::default()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let steps = undo_steps(&s); + let e = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap_err().to_string(); + assert!(e.contains("Stencil Bridges: the selected art is too large or too detailed to bridge"), "{e}"); + assert_eq!(undo_steps(&s), steps); +} + #[test] fn selected_objects_without_outlines_stay_where_they_are() { for keep in [true, false] { diff --git a/crates/engine/src/cmd/addons/cutting/weed.rs b/crates/engine/src/cmd/addons/cutting/weed.rs new file mode 100644 index 0000000000..a05770a024 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed.rs @@ -0,0 +1,160 @@ +//! Weed Box and Lines (`addon.cutting.weed`), a command of the built-in Cutting Tools add-on for +//! vinyl cutting: around the selected art it adds a weed box (a rectangle at a margin around the +//! art) and weeding lines across it that stop at the art, so the waste vinyl around the art lifts +//! off in pieces (). The art is measured by the +//! outlines a cutter cuts ([`geometry`]). + +mod geometry; +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Rect, shapes}; + +use super::super::super::edit::selected_roots; +use super::super::super::object::choice; +use super::super::super::paint::swatch_solid; +use super::super::super::pathops::{length_given, number_given}; +use super::super::super::{CommandSpec, bad, cmd, has_selection, switch_or, text_param}; +use crate::{EngineError, Result, Session}; + +/// Weed Box and Lines' command. +pub const WEED: &str = "addon.cutting.weed"; +/// Which way the weeding lines run. +pub const DIRECTIONS: [&str; 3] = ["horizontal", "vertical", "both"]; +/// The largest margin, gap or spacing, in points. +const MAX_DISTANCE: f64 = 100_000.0; +/// The most weeding lines each way. +const MAX_COUNT: usize = 1000; +/// The most pieces of weeding line one run adds. +const MAX_PIECES: usize = 100_000; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.cutting.weed", + "Weed Box and Lines…", + ["Object", "Plug-ins", "Cutting Tools"], + None, + "{margin?: pt 0..100000 (9: from the art to the weed box), box?: true (false: the weeding lines only), direction?: \"horizontal\" | \"vertical\" | \"both\" (\"both\"), count?: whole number 0..1000 of lines each way (1), spacing?: pt (instead of count: as few lines as keep them at most this far apart), gap?: pt 0..100000 (0: the lines stop at the art's outlines), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the lines are stroked in (\"[Registration]\")} add a weed box around the selected art and weeding lines across it that divide it evenly, each running from one side of the box to the other and left out over the art and within `gap` of its outlines, so the waste lifts off in pieces. The art is measured by the outlines a cutter cuts: closed paths enclose their area by their fill rule and open paths are lines, compound paths by all their subpaths, type by its glyph outlines, a clip group by what shows inside its clipping path, images by their frames, placed files by the art they show (by their boxes when the file can't be read), symbol instances by their art and live objects by the art they draw; strokes, effects and hidden objects are left out. One group named Weeding, stroked with no fill, on top of the current layer (outside the clip groups around a group edited in isolation), selected, as one undo step → {id: the group, box: its weed box or null, lines (pieces of weeding line)}", + has_selection, + weed + )] +} + +/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`. +fn distance(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, WEED)? { + None => Ok(default), + Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v), + Some(_) => Err(bad(WEED, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))), + } +} + +/// How many lines divide a side `length` long: `count`, or as few as keep them at most `spacing` +/// apart. +fn lines_across(length: f64, count: Option, spacing: Option) -> Result { + let n = match spacing { + // `length / spacing` strips, rounded up, need one line fewer. + Some(s) => ((length / s - 1e-9).ceil() - 1.0).max(0.0), + None => return Ok(count.unwrap_or(1)), + }; + if n > MAX_COUNT as f64 { + return Err(bad(WEED, format!("`spacing` makes more than {MAX_COUNT} lines across the box"))); + } + Ok(n as usize) +} + +/// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping +/// sublayer (a group edited in isolation), the container around the outermost of them. A clipping +/// path would hide the lines and the box, which lie outside the art. +fn outside_clips(d: &Document, parent: Option) -> Option { + let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })); + let (mut at, mut out) = (parent, parent); + while let Some(p) = at { + let up = d.parent_of(p); + if clips(p) { + match up { + Some(up) if d.is_editable(up) => out = Some(up), + _ => break, + } + } + at = up; + } + out +} + +fn weed(s: &mut Session, p: &Value) -> Result { + let margin = distance(p, "margin", 9.0)?; + let gap = distance(p, "gap", 0.0)?; + let with_box = switch_or(p, "box", true, WEED)?; + let direction = choice(p, WEED, "direction", &DIRECTIONS)?.unwrap_or("both"); + let count = match number_given(p, "count", WEED)? { + None => None, + Some(n) if n.fract() == 0.0 && (0.0..=MAX_COUNT as f64).contains(&n) => Some(n as usize), + Some(_) => return Err(bad(WEED, format!("`count` must be a whole number from 0 to {MAX_COUNT}"))), + }; + let spacing = match length_given(p, "spacing", WEED)? { + None => None, + Some(v) if v > 0.0 && v <= MAX_DISTANCE => Some(v), + Some(_) => return Err(bad(WEED, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))), + }; + if count.is_some() && spacing.is_some() { + return Err(bad(WEED, "give `count` or `spacing`, not both")); + } + let weight = match length_given(p, "weight", WEED)? { + None => 0.3, + Some(v) if (0.01..=1000.0).contains(&v) => v, + Some(_) => return Err(bad(WEED, "`weight` must be from 0.01 to 1000 pt")), + }; + let swatch = text_param(p, "swatch", WEED)?.unwrap_or(REGISTRATION); + let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(WEED, e))?; + let roots = selected_roots(s)?; + if roots.is_empty() { + return Err(bad(WEED, "select the art to weed")); + } + let d = &s.doc()?.doc; + let parent = outside_clips(d, s.doc()?.target_parent()?); + // Placed files' art uses their resources (symbols) under names of their own. + let placed = d.with_placed_art(); + let region = geometry::Reader::new(&placed).read_ids(&roots).map_err(|e| bad(WEED, e))?; + let region = region.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?; + let outline = region.outline().map_err(|e| bad(WEED, e))?; + let art = outline.iter().fold(None, |r: Option, &(a, b)| Some(r.map_or(Rect::from_points(a, b), |r| r.union_pt(a).union_pt(b)))); + let frame = art.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?.inflate(margin, margin); + if !(frame.width().is_finite() && frame.height().is_finite()) { + return Err(bad(WEED, "the selected art is too large to measure")); + } + let each = |on: bool, length: f64| if on { lines_across(length, count, spacing) } else { Ok(0) }; + let counts = + geometry::Counts { horizontal: each(direction != "vertical", frame.height())?, vertical: each(direction != "horizontal", frame.width())? }; + let pieces = geometry::lines(®ion, &outline, frame, counts, gap, MAX_PIECES).map_err(|e| bad(WEED, e))?; + if pieces.is_empty() && !with_box { + return Err(EngineError::Other("Weed Box and Lines: no weeding line fits outside the art, and the weed box is off".into())); + } + let ap = Appearance::basic(Paint::None, stroke, weight); + let (group, weed_box, lines) = s.edit("Weed Box and Lines", |d, sel| { + let mut children = vec![]; + let mut weed_box = None; + if with_box { + let mut b = Node::path(d.alloc_id(), shapes::rectangle(frame), ap.clone()); + b.name = Some("Weed Box".into()); + weed_box = Some(b.id); + children.push(Arc::new(b)); + } + for (a, b) in &pieces { + children.push(Arc::new(Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone()))); + } + let mut g = Node::group(d.alloc_id(), children); + g.name = Some("Weeding".into()); + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok((id, weed_box, pieces.len())) + })?; + Ok(json!({ "id": group.0, "box": weed_box.map(|b| b.0), "lines": lines })) +} diff --git a/crates/engine/src/cmd/addons/cutting/weed/geometry.rs b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs new file mode 100644 index 0000000000..4642031e71 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs @@ -0,0 +1,769 @@ +//! The geometry of Weed Box and Lines: the selected art as the outlines a cutter cuts ([`Region`]), +//! and the weeding lines across a box that stop at those outlines or at a gap from them +//! ([`lines`]). +//! +//! A region is measured along one straight line at a time. A closed outline encloses its area by +//! its fill rule, an open one is a line with no area, a group is the union of its members and a +//! clip group is what its members draw inside its clipping path. Along a weeding line the region +//! gives the stretches inside its area ([`Region::spans`]), and its outline pieces +//! ([`Region::outline`]) give the stretches within the gap of an outline. + +use std::cell::Cell; + +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{BezPath, FillRule, PathEl, Point, Rect}; + +use super::super::super::super::pathops::node_path; + +/// How closely curves are followed, in points. +const TOLERANCE: f64 = 0.02; +/// The most points the outlines of one selection are measured by, once curves are followed in +/// straight segments (each subpath's points, its first one included). +pub(super) const MAX_POINTS: usize = 200_000; +/// The most tests of a segment against another one measuring a clip group's outlines takes. +const MAX_PAIRS: usize = 40_000_000; +/// The deepest groups, symbol instances and live objects are opened inside one another: as deep +/// as any object is drawn. +pub(super) const MAX_DEPTH: usize = vectorcraft_doc::depth::MAX_DRAWN_DEPTH; +/// The most pairs of an outline segment and a weeding line it comes near that one run measures. +const MAX_NEAR: usize = 10_000_000; +/// Pieces of weeding line shorter than this, in points, are left out. +const MIN_PIECE: f64 = 0.01; + +/// A straight segment. +pub(super) type Segment = (Point, Point); + +/// One subpath followed in straight segments. +#[derive(Clone, Debug)] +pub(super) struct Poly { + points: Vec, + closed: bool, + /// The box around `points`. + bounds: Rect, +} + +impl Poly { + /// The segments, with the closing one of a closed subpath. + fn segments(&self) -> impl Iterator + '_ { + let closing = self.closed.then(|| (self.points.last().copied(), self.points.first().copied())); + self.points.windows(2).map(|w| (w[0], w[1])).chain(closing.and_then(|(a, b)| Some((a?, b?)))) + } +} + +/// The selected art as the outlines a cutter cuts. +#[derive(Clone, Debug)] +pub(super) enum Region { + /// A path, compound path or type: its subpaths with the rule its closed ones fill by. + Shape { polys: Vec, rule: FillRule }, + /// Everything its members draw. + Union(Vec), + /// What `content` draws inside `clip`. + Clip { clip: Box, content: Box }, +} + +/// Reads the selected objects into a [`Region`], keeping count of the outline points. +pub(super) struct Reader<'a> { + doc: &'a Document, + points: usize, +} + +impl<'a> Reader<'a> { + pub(super) fn new(doc: &'a Document) -> Self { + Self { doc, points: 0 } + } + + /// The region of `nodes` together, `None` when they hold no outlines. + pub(super) fn read(&mut self, nodes: &[&Node]) -> Result, String> { + let mut parts = vec![]; + for n in nodes { + parts.extend(self.node(n, 0)?); + } + Ok(union(parts)) + } + + fn node(&mut self, n: &Node, depth: usize) -> Result, String> { + if depth > MAX_DEPTH { + return Err(format!("the selected art is nested more than {MAX_DEPTH} levels deep")); + } + if !n.visible { + return Ok(None); + } + match &n.kind { + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => Ok(None), + NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => self.shape(n), + NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } => { + let members = n.editing_children(); + if *clip { + let Some((first, rest)) = members.split_first() else { return Ok(None) }; + let Some(clip) = self.clip_region(first)? else { return Ok(None) }; + let mut parts = vec![]; + for c in rest { + parts.extend(self.node(c, depth + 1)?); + } + Ok(union(parts).map(|content| Region::Clip { clip: Box::new(clip), content: Box::new(content) })) + } else { + let mut parts = vec![]; + for c in members { + parts.extend(self.node(c, depth + 1)?); + } + Ok(union(parts)) + } + } + NodeKind::Image(im) => { + let (w, h) = (f64::from(im.width), f64::from(im.height)); + let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)].map(|p| im.xf * p); + self.polygon(&corners) + } + // An instance of a symbol the document doesn't have draws nothing. + NodeKind::SymbolInstance { .. } => match vectorcraft_render::effects::outline_object(Some(self.doc), n) { + Some(art) => self.node(&art, depth + 1), + None => Ok(None), + }, + NodeKind::PlacedDocument(p) => { + // The art of the file it shows; a file that can't be read shows its preview in its + // box, which may be rotated. + if vectorcraft_doc::placed_document::read_cached(p).is_none() { + return self.polygon(&p.corners()); + } + let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n); + if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) { + self.polygon(&p.corners()) + } else { + self.node(&art, depth + 1) + } + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { + // The art a live object draws, as the exporters write it; an object that stays + // as it is is measured by its box. + let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n); + if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) { self.frame(n) } else { self.node(&art, depth + 1) } + } + } + } + + /// The region a clipping path `first` clips to, whatever its own visibility: the region the + /// renderer clips to (a path, a compound path, type by its glyphs, a group's visible members, + /// an image's frame). `None` when it clips to nothing, and the clipped art is hidden. + fn clip_region(&mut self, first: &Node) -> Result, String> { + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(first) else { return Ok(None) }; + let mut polys = self.polys(outline)?; + // A region is filled: an open subpath clips as if closed. + polys.iter_mut().for_each(|p| p.closed = true); + Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule })) + } + + /// The region of the objects `ids` together, each within the clipping paths of the clip groups + /// and layers around it (an object selected inside a clip group shows only within its clipping + /// path, and not at all inside a hidden group or layer; a clipping path is measured as its clip + /// group), `None` when they show no outlines. + pub(super) fn read_ids(&mut self, ids: &[NodeId]) -> Result, String> { + let doc = self.doc; + let clips = |n: &Node| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + // Every object's place in the tree, found in one walk. + let places = doc.index_paths(ids.iter().copied()); + let mut paths = vec![]; + for id in ids { + let Some(mut path) = places.get(id).cloned() else { continue }; + // A clipping path selected on its own stands for the art it shows: its clip group. + while let (Some(n), Some(up)) = (doc.node_at(&path), path.split_last().map(|(_, up)| up).and_then(|up| doc.node_at(up))) { + if !(clips(up) && up.editing_children().first().is_some_and(|f| f.id == n.id)) { + break; + } + path.pop(); + } + paths.push(path); + } + // An object selected twice, or inside another selected object, is measured once, with it. + paths.sort(); + paths.dedup(); + let mut kept: Vec> = vec![]; + for path in paths { + if !kept.last().is_some_and(|k| path.starts_with(k)) { + kept.push(path); + } + } + let mut parts = vec![]; + 'ids: for path in &kept { + let Some(n) = doc.node_at(path) else { continue }; + // Art in a hidden group or layer isn't shown, and isn't measured. + if (1..path.len()).any(|depth| path.get(..depth).and_then(|p| doc.node_at(p)).is_some_and(|c| !c.visible)) { + continue; + } + let Some(mut region) = self.read(&[n])? else { continue }; + // The containers around the object, innermost first. + let mut child = n.id; + for depth in (1..path.len()).rev() { + let Some(container) = path.get(..depth).and_then(|p| doc.node_at(p)) else { break }; + if clips(container) + && let Some(first) = container.editing_children().first().filter(|f| f.id != child) + { + let Some(clip) = self.clip_region(first)? else { continue 'ids }; + region = Region::Clip { clip: Box::new(clip), content: Box::new(region) }; + } + child = container.id; + } + parts.push(region); + } + Ok(union(parts)) + } + + /// A path, compound path or type as its outlines. + fn shape(&mut self, n: &Node) -> Result, String> { + // Each anchor is at least one point, so a path past the budget errs before it is copied. + if anchors(n) > MAX_POINTS.saturating_sub(self.points) { + return Err(too_many()); + } + let Some((path, rule)) = node_path(n) else { return Ok(None) }; + let polys = self.polys(path.to_bezpath())?; + Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule })) + } + + /// The box of an object whose outlines can't be read. + fn frame(&mut self, n: &Node) -> Result, String> { + match n.geometric_bounds_with(&self.doc.symbol_boxes()) { + Some(r) => self.polygon(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]), + None => Ok(None), + } + } + + /// A closed polygon through `points`. + fn polygon(&mut self, points: &[Point]) -> Result, String> { + let poly = poly(points.to_vec(), true); + self.count(poly.iter().map(|p| p.points.len()).sum())?; + Ok(poly.map(|p| Region::Shape { polys: vec![p], rule: FillRule::NonZero })) + } + + fn count(&mut self, n: usize) -> Result<(), String> { + self.points = self.points.saturating_add(n); + if self.points > MAX_POINTS { Err(too_many()) } else { Ok(()) } + } + + /// The subpaths of `path` followed in straight segments. + fn polys(&mut self, path: BezPath) -> Result, String> { + let (mut out, mut current, points) = (vec![], vec![], Cell::new(0usize)); + let left = MAX_POINTS.saturating_sub(self.points); + // Past the budget no further curve is followed (one curve gives at most + // `MAX_FLATTEN_PER_CURVE` points), and the read fails below. + let elements = path.into_iter().take_while(|_| points.get() <= left); + vectorcraft_geom::flatten(elements, TOLERANCE, |el| { + points.set(points.get() + 1); + if points.get() > left { + return; + } + match el { + PathEl::MoveTo(p) => { + out.extend(poly(std::mem::take(&mut current), false)); + current.push(p); + } + PathEl::LineTo(p) => current.push(p), + PathEl::ClosePath => out.extend(poly(std::mem::take(&mut current), true)), + PathEl::QuadTo(..) | PathEl::CurveTo(..) => {} + } + }); + if points.get() > left { + return Err(too_many()); + } + out.extend(poly(current, false)); + self.count(out.iter().map(|p: &Poly| p.points.len()).sum())?; + Ok(out) + } +} + +fn too_many() -> String { + format!("the selected art has too many outline points to measure (more than {MAX_POINTS})") +} + +/// The anchors of a path or compound path node, read without copying its path; 0 for type. +fn anchors(n: &Node) -> usize { + match &n.kind { + NodeKind::Path { path, .. } => path.anchor_count(), + NodeKind::Compound { children, .. } => children.iter().fold(0, |sum, c| sum.saturating_add(anchors(c))), + _ => 0, + } +} + +/// A subpath through `points`, `None` for fewer than two points or one that isn't finite. +fn poly(points: Vec, closed: bool) -> Option { + if points.len() < 2 || points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return None; + } + let first = *points.first()?; + let bounds = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)); + Some(Poly { points, closed, bounds }) +} + +/// One region of `parts`, `None` when there are none. +fn union(mut parts: Vec) -> Option { + match parts.len() { + 0 => None, + 1 => parts.pop(), + _ => Some(Region::Union(parts)), + } +} + +/// `p` as (along, across) a weeding line: horizontal lines run along x, vertical ones along y. +fn uv(p: Point, vertical: bool) -> (f64, f64) { + if vertical { (p.y, p.x) } else { (p.x, p.y) } +} + +/// Sorts `spans` and joins those that overlap or touch. +fn merged(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f64, f64)> = Vec::with_capacity(spans.len()); + for (a, b) in spans { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + out +} + +/// Where sorted, joined spans `a` and `b` overlap. +fn intersected(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { + let (mut i, mut j, mut out) = (0, 0, vec![]); + while let (Some(&(a0, a1)), Some(&(b0, b1))) = (a.get(i), b.get(j)) { + let (lo, hi) = (a0.max(b0), a1.min(b1)); + if lo <= hi { + out.push((lo, hi)); + } + if a1 < b1 { + i += 1; + } else { + j += 1; + } + } + out +} + +/// Where segment a→b crosses the line `across` = `c`, along it, and which way (1 when `across` +/// grows from a to b), `None` when it doesn't. Each crossing counts once: an end exactly on the +/// line counts as past it. +fn crosses(a: Point, b: Point, vertical: bool, c: f64) -> Option<(f64, i32)> { + let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical)); + ((v0 <= c && v1 > c) || (v1 <= c && v0 > c)).then(|| (u0 + (c - v0) * (u1 - u0) / (v1 - v0), if v1 > v0 { 1 } else { -1 })) +} + +/// The stretches inside an area filled by `rule`, from where its outlines cross a line, sorted +/// along it. +fn filled(crossings: impl IntoIterator, rule: FillRule) -> Vec<(f64, f64)> { + let (mut winding, mut start, mut out) = (0, 0.0, vec![]); + let inside = |w: i32| match rule { + FillRule::NonZero => w != 0, + FillRule::EvenOdd => w % 2 != 0, + }; + for (u, dir) in crossings { + let was = inside(winding); + winding += dir; + match (was, inside(winding)) { + (false, true) => start = u, + (true, false) => out.push((start, u)), + _ => {} + } + } + merged(out) +} + +/// Where the closed outlines of a region's shapes cross a set of weeding lines: for each line, the +/// crossings as (shape, place along the line, direction), sorted by shape and then by place. Shapes +/// are numbered in the order [`Region::shapes`] lists them. +pub(super) type Crossings = Vec>; + +/// Where the shapes of each part of a region are numbered among its [`Crossings`]. +#[derive(Debug)] +pub(super) enum Layout { + Shape, + /// The number of each part's first shape, from the union's first, and the parts' layouts. + Union { + starts: Vec, + parts: Vec, + }, + /// The clipping path's shapes come first. + Clip { + clip_shapes: usize, + clip: Box, + content: Box, + }, +} + +impl Layout { + /// The layout of `region` and how many shapes it has. + pub(super) fn of(region: &Region) -> (Layout, usize) { + match region { + Region::Shape { .. } => (Layout::Shape, 1), + Region::Union(parts) => { + let (mut starts, mut layouts, mut n) = (vec![], vec![], 0); + for r in parts { + let (l, k) = Layout::of(r); + starts.push(n); + layouts.push(l); + n += k; + } + (Layout::Union { starts, parts: layouts }, n) + } + Region::Clip { clip, content } => { + let ((lc, a), (lt, b)) = (Layout::of(clip), Layout::of(content)); + (Layout::Clip { clip_shapes: a, clip: Box::new(lc), content: Box::new(lt) }, a + b) + } + } + } +} + +/// The [`Crossings`] of `region` with the lines `across` = v0 + (k + 1)·step, k < `count`. Errs +/// past [`MAX_NEAR`] crossings in all. +pub(super) fn crossings(region: &Region, vertical: bool, v0: f64, step: f64, count: usize) -> Result { + let mut shapes = vec![]; + region.shapes(&mut shapes); + let at = |k: usize| v0 + step * (k as f64 + 1.0); + // The lines a segment may cross: those from just below its lower end to its higher end. + let candidates = |(a, b): Segment| { + let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1); + if step <= 0.0 || va == vb { + return 0..0; + } + let first = (((va.min(vb) - v0) / step).floor() - 1.0).clamp(0.0, count as f64); + let end = ((va.max(vb) - v0) / step).ceil().clamp(0.0, count as f64); + (first as usize)..(end as usize) + }; + let closed = |polys: &[Poly]| polys.iter().filter(|p| p.closed).flat_map(Poly::segments).collect::>(); + let total = shapes.iter().flat_map(|(polys, _)| closed(polys)).fold(0usize, |n, s| n.saturating_add(candidates(s).len())); + if total > MAX_NEAR { + return Err("the weeding lines cross the art's outlines too many times to measure; use fewer lines".into()); + } + let mut out: Crossings = vec![vec![]; count]; + for (i, (polys, _)) in shapes.iter().enumerate() { + for (a, b) in closed(polys) { + for k in candidates((a, b)) { + if let (Some((u, dir)), Some(line)) = (crosses(a, b, vertical, at(k)), out.get_mut(k)) { + line.push((i, u, dir)); + } + } + } + } + for line in &mut out { + line.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))); + } + Ok(out) +} + +impl Region { + /// The stretches of the line `across` = `c` (horizontal: y = c, along x; vertical: x = c, + /// along y) inside the region's area, sorted and joined. + pub(super) fn spans(&self, vertical: bool, c: f64) -> Vec<(f64, f64)> { + match self { + Region::Shape { polys, rule } => { + let mut crossings: Vec<(f64, i32)> = vec![]; + for p in polys.iter().filter(|p| p.closed) { + let (lo, hi) = if vertical { (p.bounds.x0, p.bounds.x1) } else { (p.bounds.y0, p.bounds.y1) }; + if c >= lo && c <= hi { + crossings.extend(p.segments().filter_map(|(a, b)| crosses(a, b, vertical, c))); + } + } + crossings.sort_by(|a, b| a.0.total_cmp(&b.0)); + filled(crossings, *rule) + } + Region::Union(parts) => merged(parts.iter().flat_map(|r| r.spans(vertical, c)).collect()), + Region::Clip { clip, content } => intersected(&clip.spans(vertical, c), &content.spans(vertical, c)), + } + } + + /// The region's shapes with their fill rules, in a fixed order. + fn shapes<'a>(&'a self, out: &mut Vec<(&'a [Poly], FillRule)>) { + match self { + Region::Shape { polys, rule } => out.push((polys, *rule)), + Region::Union(parts) => parts.iter().for_each(|r| r.shapes(out)), + Region::Clip { clip, content } => { + clip.shapes(out); + content.shapes(out); + } + } + } + + /// [`Region::spans`] on a weeding line, from `line`, the line's [`Crossings`] with the + /// region's shapes, numbered from `base` (`layout` is the region's [`Layout`]). Only the parts + /// the line crosses are visited: a part it doesn't cross has no stretch inside its area. + pub(super) fn spans_on(&self, layout: &Layout, line: &[(usize, f64, i32)], base: usize) -> Vec<(f64, f64)> { + if line.is_empty() { + return vec![]; + } + match (self, layout) { + (Region::Shape { rule, .. }, Layout::Shape) => filled(line.iter().map(|&(_, u, dir)| (u, dir)), *rule), + (Region::Union(parts), Layout::Union { starts, parts: layouts }) => { + let (mut out, mut rest) = (vec![], line); + while let Some(&(id, ..)) = rest.first() { + // The part whose shapes the next crossing is on, and its crossings. + let j = starts.partition_point(|&s| base + s <= id).saturating_sub(1); + let (Some(r), Some(l), Some(&start)) = (parts.get(j), layouts.get(j), starts.get(j)) else { break }; + let end = starts.get(j + 1).map_or(usize::MAX, |&s| base + s); + let n = rest.partition_point(|c| c.0 < end); + if n == 0 { + break; + } + let (mine, after) = rest.split_at(n); + out.extend(r.spans_on(l, mine, base + start)); + rest = after; + } + merged(out) + } + (Region::Clip { clip, content }, Layout::Clip { clip_shapes, clip: lc, content: lt }) => { + let (a, b) = line.split_at(line.partition_point(|c| c.0 < base + clip_shapes)); + if a.is_empty() || b.is_empty() { + return vec![]; + } + intersected(&clip.spans_on(lc, a, base), &content.spans_on(lt, b, base + clip_shapes)) + } + _ => vec![], + } + } + + /// Is `p` inside the region's area or on one of `segments`, the outlines it shows? + fn contains(&self, p: Point, segments: &[Segment]) -> bool { + const EPS: f64 = 1e-7; + self.spans(false, p.y).iter().any(|&(a, b)| p.x >= a - EPS && p.x <= b + EPS) || segments.iter().any(|&(a, b)| distance(p, a, b) <= EPS) + } + + /// The pieces of outline the region shows: every outline of a shape and of a group's members, + /// and in a clip group the members' outlines inside the clipping path and the clipping path's + /// outline where the members' area or outlines are. Errs past [`MAX_PAIRS`], counted over + /// every clip group in the region together. + pub(super) fn outline(&self) -> Result, String> { + self.outline_counted(&mut 0) + } + + /// [`Region::outline`], adding the measure's work to `work`. + fn outline_counted(&self, work: &mut usize) -> Result, String> { + match self { + Region::Shape { polys, .. } => Ok(polys.iter().flat_map(Poly::segments).collect()), + Region::Union(parts) => { + let mut out = vec![]; + for r in parts { + out.extend(r.outline_counted(work)?); + } + Ok(out) + } + Region::Clip { clip, content } => { + // Each side's area is bounded by the outlines it shows, so the pieces are cut and + // tested against those, never against art a nested clip hides. + let (clip_shown, content_shown) = (clip.outline_counted(work)?, content.outline_counted(work)?); + let mut out = within(&content_shown, clip, &clip_shown, work)?; + out.extend(within(&clip_shown, content, &content_shown, work)?); + Ok(out) + } + } + } + + /// The points of the region's closed outlines, those a nested clip hides included: what one + /// [`Region::spans`] looks at, at most. + fn closed_points(&self) -> usize { + match self { + Region::Shape { polys, .. } => polys.iter().filter(|p| p.closed).map(|p| p.points.len()).sum(), + Region::Union(parts) => parts.iter().map(Region::closed_points).sum(), + Region::Clip { clip, content } => clip.closed_points().saturating_add(content.closed_points()), + } + } +} + +fn too_complex_clip() -> String { + "a clip group in the selection has too many outline segments to measure".into() +} + +/// The pieces of `segments` inside `region` (or on its outlines), cut where they cross +/// `region_segments`, the outlines the region shows. Each segment, and each piece of one tested +/// for being inside, adds the region's closed outline points (hidden ones included) and its +/// shown segment count to `work`, and the measure errs past [`MAX_PAIRS`]. +fn within(segments: &[Segment], region: &Region, region_segments: &[Segment], work: &mut usize) -> Result, String> { + let mut out = vec![]; + let cost = region.closed_points().saturating_add(region_segments.len()).max(1); + let charge = |work: &mut usize| { + *work = work.saturating_add(cost); + if *work > MAX_PAIRS { Err(too_complex_clip()) } else { Ok(()) } + }; + for &(a, b) in segments { + charge(work)?; + let mut cuts = vec![0.0, 1.0]; + let seg_box = Rect::from_points(a, b); + for &(c, d) in region_segments { + if touches(seg_box, Rect::from_points(c, d)) { + cuts.extend(crossing(a, b, c, d)); + } + } + cuts.sort_by(f64::total_cmp); + cuts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); + let at = |t: f64| a + (b - a) * t; + for w in cuts.windows(2) { + let (t0, t1) = (w[0], w[1]); + if t1 - t0 < 1e-12 { + continue; + } + charge(work)?; + if region.contains(at((t0 + t1) / 2.0), region_segments) { + out.push((at(t0), at(t1))); + } + } + } + Ok(out) +} + +/// Do boxes `a` and `b` overlap or touch? +fn touches(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Where along a→b (0 to 1) segment c→d crosses or touches it: one place, or the ends of the +/// shared stretch when the two lie on one line. +fn crossing(a: Point, b: Point, c: Point, d: Point) -> Vec { + let (r, s) = (b - a, d - c); + let denom = r.cross(s); + let qp = c - a; + let len2 = r.hypot2(); + if len2 == 0.0 { + return vec![]; + } + if denom.abs() <= 1e-12 * len2.sqrt() * s.hypot().max(1e-300) { + // Parallel: the ends of c→d on a→b when the two lie on one line. + if qp.cross(r).abs() > 1e-9 * len2.sqrt() { + return vec![]; + } + return [c, d].iter().map(|p| (*p - a).dot(r) / len2).filter(|t| (0.0..=1.0).contains(t)).collect(); + } + let t = qp.cross(s) / denom; + let u = qp.cross(r) / denom; + if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) { vec![t] } else { vec![] } +} + +/// The distance from `p` to segment a→b. +fn distance(p: Point, a: Point, b: Point) -> f64 { + let ab = b - a; + let len2 = ab.hypot2(); + let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (p - (a + ab * t)).hypot() +} + +/// The stretch of the line `across` = `c` within `gap` of segment a→b, `None` when the line +/// passes farther away. With no gap it is where the segment meets the line. +fn near(a: Point, b: Point, vertical: bool, c: f64, gap: f64) -> Option<(f64, f64)> { + let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical)); + if gap <= 0.0 { + if v0 == v1 { + return (v0 == c).then_some((u0.min(u1), u0.max(u1))); + } + if c < v0.min(v1) || c > v0.max(v1) { + return None; + } + let u = u0 + (c - v0) * (u1 - u0) / (v1 - v0); + return Some((u, u)); + } + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + let mut take = |x: f64, y: f64| { + lo = lo.min(x); + hi = hi.max(y); + }; + // The round ends. + for (u, v) in [(u0, v0), (u1, v1)] { + let dv = (c - v).abs(); + if dv <= gap { + let h = (gap * gap - dv * dv).max(0.0).sqrt(); + take(u - h, u + h); + } + } + // The band along the segment: points whose foot falls on it, within the gap of it. + let (du, dv) = (u1 - u0, v1 - v0); + let len = du.hypot(dv); + if len > 0.0 { + // Each condition is linear in the position x along the line: k·x + m ≥ 0. + let mut range = (f64::NEG_INFINITY, f64::INFINITY); + let mut keep = |k: f64, m: f64| { + if k == 0.0 { + if m < 0.0 { + range = (1.0, 0.0); + } + } else if k > 0.0 { + range.0 = range.0.max(-m / k); + } else { + range.1 = range.1.min(-m / k); + } + }; + // The foot at or after a: (x - u0)·du + (c - v0)·dv ≥ 0. + keep(du, -u0 * du + (c - v0) * dv); + // The foot at or before b. + keep(-du, u0 * du - (c - v0) * dv + len * len); + // Within the gap on either side: |(x - u0)·dv - (c - v0)·du| ≤ gap·len. + let cross0 = -u0 * dv - (c - v0) * du; + keep(-dv, -cross0 + gap * len); + keep(dv, cross0 + gap * len); + if range.0 <= range.1 { + take(range.0, range.1); + } + } + (lo <= hi).then_some((lo, hi)) +} + +/// How many weeding lines run each way. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Counts { + pub horizontal: usize, + pub vertical: usize, +} + +/// The weeding lines across `frame`: `n.horizontal` horizontal and `n.vertical` vertical lines that +/// divide it evenly, each from one side of the frame to the other, left out where it is inside the +/// region's area or within `gap` of one of its outlines (`outline`). Pieces are in order along each +/// line, the horizontal lines first, top to bottom. Errs past [`MAX_NEAR`] pairs of an outline +/// segment and a line it comes within `gap` of, past [`MAX_NEAR`] crossings of the region's +/// outlines with the lines one way, or past `max_pieces` pieces. +pub(super) fn lines(region: &Region, outline: &[Segment], frame: Rect, n: Counts, gap: f64, max_pieces: usize) -> Result, String> { + let mut out = vec![]; + let (layout, _) = Layout::of(region); + for (vertical, count) in [(false, n.horizontal), (true, n.vertical)] { + let ((u0, v0), (u1, v1)) = (uv(Point::new(frame.x0, frame.y0), vertical), uv(Point::new(frame.x1, frame.y1), vertical)); + let step = (v1 - v0) / (count as f64 + 1.0); + // A frame with no height (for horizontal lines) or no width has no waste to divide. + if step <= 0.0 { + continue; + } + // The lines (0-based) each outline segment comes within `gap` of: lines k (1-based) lie at + // v0 + k·step. + let reach = |&(a, b): &Segment| { + let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1); + let (lo, hi) = (va.min(vb) - gap, va.max(vb) + gap); + let first = ((lo - v0) / step).ceil().max(1.0); + let last = ((hi - v0) / step).floor().min(count as f64); + if step > 0.0 && first <= last { (first as usize - 1)..(last as usize) } else { 0..0 } + }; + // The outlines near each line, found once for all of them, by index into `outline`. + let pairs = outline.iter().fold(0usize, |sum, s| sum.saturating_add(reach(s).len())); + if pairs > MAX_NEAR { + return Err("the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap".into()); + } + let mut near_line: Vec> = vec![vec![]; count]; + for (j, s) in outline.iter().enumerate() { + for k in reach(s) { + if let Some(list) = near_line.get_mut(k) { + list.push(j); + } + } + } + let crossed = crossings(region, vertical, v0, step, count)?; + for (i, segments) in near_line.iter().enumerate() { + let c = v0 + step * (i as f64 + 1.0); + let mut blocked = region.spans_on(&layout, crossed.get(i).map(Vec::as_slice).unwrap_or_default(), 0); + blocked.extend(segments.iter().filter_map(|&j| outline.get(j)).filter_map(|&(a, b)| near(a, b, vertical, c, gap))); + let mut from = u0; + let point = |u: f64| if vertical { Point::new(c, u) } else { Point::new(u, c) }; + for (a, b) in merged(blocked) { + if a - from >= MIN_PIECE && a <= u1 { + out.push((point(from), point(a.min(u1)))); + } + from = from.max(b); + } + if u1 - from >= MIN_PIECE { + out.push((point(from), point(u1))); + } + if out.len() > max_pieces { + return Err(format!("the weeding lines would be more than {max_pieces} pieces")); + } + } + } + Ok(out) +} diff --git a/crates/engine/src/cmd/addons/cutting/weed/tests.rs b/crates/engine/src/cmd/addons/cutting/weed/tests.rs new file mode 100644 index 0000000000..1003ef1db3 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed/tests.rs @@ -0,0 +1,915 @@ +//! Tests for `addon.cutting.weed`: a weed box around the selected art and weeding lines across it +//! that stop at the art's outlines. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes}; + +use super::super::super::super::pathops::text_outline; +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// Runs Weed Box and Lines with `p` and gives the group it made. +fn weed(s: &mut Session, p: Value) -> (Value, Node) { + let r = s.execute("addon.cutting.weed", &p).unwrap(); + let g = node(s, id_of(&r)); + (r, g) +} + +/// The weed box's rectangle in group `g`, if it has one. +fn weed_box(g: &Node) -> Option { + g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).map(|c| c.geometric_bounds().unwrap()) +} + +/// The weeding lines in group `g` as their two ends, rounded to 1/1000 pt. +fn lines(g: &Node) -> Vec<((f64, f64), (f64, f64))> { + let r = |v: f64| (v * 1000.0).round() / 1000.0; + g.children() + .unwrap() + .iter() + .filter(|c| c.name.is_none()) + .map(|c| match &c.kind { + NodeKind::Path { path, .. } => { + let a: Vec = path.anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(a.len(), 2, "a weeding line is one straight segment"); + ((r(a[0].x), r(a[0].y)), (r(a[1].x), r(a[1].y))) + } + other => panic!("not a path: {other:?}"), + }) + .collect() +} + +#[test] +fn a_weed_box_and_lines_go_around_a_rectangle() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let undo = s.doc().unwrap().history.undo.len(); + let (r, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(g.name.as_deref(), Some("Weeding")); + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0))); + // One line each way through the middle, left out over the art. + assert_eq!( + lines(&g), + [((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (160.0, 120.0)), ((125.0, 90.0), (125.0, 100.0)), ((125.0, 140.0), (125.0, 150.0))] + ); + assert_eq!(r["lines"], json!(4)); + assert_eq!(r["box"], json!(weed_box_id(&g))); + // Stroked in Registration with no fill, selected, as one undo step that takes it all away. + for c in g.children().unwrap() { + assert_eq!(c.appearance.fill_paint(), Paint::None, "no fill"); + assert!(c.appearance.stroke_paint().is_registration()); + } + assert_eq!(s.doc().unwrap().selection.objects, [g.id]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g.id).is_none()); +} + +fn weed_box_id(g: &Node) -> u64 { + g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).unwrap().id.0 +} + +#[test] +fn lines_stop_at_the_outline_and_cross_the_hole_of_a_compound_path() { + let mut s = session(); + let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0); + select(&mut s, &[outer, inner]); + let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + // Both subpaths wind the same way, so the fill rule alone decides whether the inner one is a + // hole. + let same_way = |rule: FillRule| { + move |d: &mut vectorcraft_doc::Document, _: &mut vectorcraft_doc::Selection| -> Result<()> { + let Some(NodeKind::Compound { children, rule: r }) = d.node_mut(c).map(|n| &mut n.kind) else { panic!("not a compound path") }; + *r = rule; + let area = |n: &Node| match &n.kind { + NodeKind::Path { path, .. } => path.subpaths[0].signed_area(), + _ => panic!("not a path"), + }; + let outer = area(&children[0]).signum(); + for child in children.iter_mut() { + if area(child).signum() != outer + && let NodeKind::Path { path, .. } = &mut std::sync::Arc::make_mut(child).kind + { + path.reverse(); + } + } + Ok(()) + } + }; + s.edit("even-odd", same_way(FillRule::EvenOdd)).unwrap(); + select(&mut s, &[c]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The waste in the hole is cut too; the ring itself is not. + assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((130.0, 150.0), (170.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]); + // With the non-zero rule the inner subpath fills, and no line crosses it. + s.edit("non-zero", same_way(FillRule::NonZero)).unwrap(); + select(&mut s, &[c]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]); +} + +#[test] +fn a_gap_keeps_the_lines_away_from_the_outline() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let (_, g) = weed(&mut s, json!({"margin": 20, "gap": 5, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(80.0, 80.0, 170.0, 160.0))); + assert_eq!(lines(&g), [((80.0, 120.0), (95.0, 120.0)), ((155.0, 120.0), (170.0, 120.0))]); + // The gap is a distance from the outline: a line passing 5 pt beside a corner, with a gap of + // 6, stops √11 pt short of the corner's height. + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let b = rect(&mut s, 200.0, 60.0, 10.0, 120.0); + select(&mut s, &[a, b]); + let (_, g) = weed(&mut s, json!({"margin": 0, "gap": 6, "direction": "vertical"})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 60.0, 210.0, 180.0))); + assert_eq!(lines(&g), [((155.0, 60.0), (155.0, 96.683)), ((155.0, 143.317), (155.0, 180.0))]); +} + +#[test] +fn a_box_with_no_height_gets_no_lines_across_it() { + // A horizontal line with no margin: the box is as flat as the line, and no weeding line runs + // along it. + let mut s = session(); + let l = line(&mut s, &[(100.0, 150.0), (200.0, 150.0)]); + let n = s.doc().unwrap().doc.node_count(); + assert!(s.execute("addon.cutting.weed", &json!({"margin": 0, "direction": "horizontal", "box": false})).is_err()); + assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing to add"); + select(&mut s, &[l]); + let (_, g) = weed(&mut s, json!({"margin": 0, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 150.0, 200.0, 150.0))); + assert_eq!(lines(&g), []); +} + +#[test] +fn an_open_path_stops_a_line_where_it_crosses() { + let mut s = session(); + line(&mut s, &[(100.0, 100.0), (100.0, 200.0)]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal", "gap": 2})); + assert_eq!(lines(&g), [((90.0, 150.0), (98.0, 150.0)), ((102.0, 150.0), (110.0, 150.0))]); +} + +#[test] +fn type_is_measured_by_its_glyph_outlines() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "OHO", "size": 72})).unwrap()); + let glyphs = match &node(&s, t).kind { + NodeKind::Text(t) => text_outline(t), + _ => panic!("not type"), + }; + let ink = glyphs.bounds().unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10, "count": 3, "direction": "horizontal"})); + let b = weed_box(&g).unwrap(); + for (got, want) in [(b.x0, ink.x0 - 10.0), (b.y0, ink.y0 - 10.0), (b.x1, ink.x1 + 10.0), (b.y1, ink.y1 + 10.0)] { + assert!((got - want).abs() < 0.05, "the box is 10 pt around the glyphs: {b:?} vs {ink:?}"); + } + // No piece of line runs over a glyph: each piece's middle is outside the ink. + let shape = glyphs.to_bezpath(); + let pieces = lines(&g); + assert!(pieces.len() > 3, "the letters break the lines: {pieces:?}"); + for ((x0, y0), (x1, y1)) in pieces { + let mid = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0); + assert_eq!(shape.winding(mid), 0, "{mid:?} is outside the glyphs"); + } +} + +#[test] +fn a_clip_group_is_measured_by_what_shows() { + let mut s = session(); + let big = rect(&mut s, 0.0, 0.0, 400.0, 300.0); + let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + select(&mut s, &[big, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&g).len(), 4); + // Art that shows only partly: the clip path's edge where the art is, and the art's edge + // inside the clip. + let mut s = session(); + let art = rect(&mut s, 120.0, 0.0, 100.0, 300.0); + let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + select(&mut s, &[art, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&g), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]); +} + +#[test] +fn a_group_as_the_clipping_path_clips_by_its_members() { + // Make Clipping Mask takes a path, a compound path or type; an opened file can clip by a + // group, as this one does. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("clip group", |d, sel| { + let mut r = |x: f64, w: f64, y: f64, h: f64| { + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + w, y + h)), Appearance::default())) + }; + let (a, b, art) = (r(100.0, 50.0, 100.0, 40.0), r(250.0, 50.0, 100.0, 40.0), r(0.0, 400.0, 0.0, 300.0)); + let clip = Arc::new(Node::group(d.alloc_id(), vec![a, b])); + let mut g = Node::group(d.alloc_id(), vec![clip, art]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The art shows through both rectangles, and the line is cut between them too. + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 310.0, 150.0))); + assert_eq!(lines(&g), [((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (250.0, 120.0)), ((300.0, 120.0), (310.0, 120.0))]); +} + +#[test] +fn lines_near_too_many_outline_segments_are_refused() { + let mut s = session(); + // A zigzag of 12,000 segments; with a gap as tall as the box, each of 1,000 lines comes near + // every segment. + let mut zigzag = BezPath::new(); + zigzag.move_to((100.0, 100.0)); + for i in 1..=12_000 { + zigzag.line_to((100.0 + f64::from(i) * 0.01, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("zigzag", |d, sel| { + let n = Node::path(d.alloc_id(), PathData::from_bezpath(&zigzag), Appearance::default()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"count": 1000, "gap": 50, "direction": "horizontal"})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); + // Fewer lines measure. + s.execute("addon.cutting.weed", &json!({"count": 10, "gap": 50, "direction": "horizontal"})).unwrap(); +} + +/// Adds a clip group, clipping path `clip` over `art` (both unpainted), and selects it. +fn clip_group(s: &mut Session, clip: PathData, art: PathData) -> NodeId { + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("clip group", |d, sel| { + let clip = Arc::new(Node::path(d.alloc_id(), clip, Appearance::default())); + let art = Arc::new(Node::path(d.alloc_id(), art, Appearance::default())); + let mut g = Node::group(d.alloc_id(), vec![clip, art]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(s, &[id]); + id +} + +#[test] +fn an_open_clipping_path_clips_as_if_closed() { + let mut s = session(); + let mut v = BezPath::new(); + v.move_to((100.0, 100.0)); + v.line_to((150.0, 200.0)); + v.line_to((200.0, 100.0)); + clip_group(&mut s, PathData::from_bezpath(&v), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 300.0))); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The art shows inside the triangle the V closes; the line stops at its sides. + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 210.0, 210.0))); + assert_eq!(lines(&g), [((90.0, 150.0), (125.0, 150.0)), ((175.0, 150.0), (210.0, 150.0))]); +} + +#[test] +fn art_a_nested_clip_hides_adds_no_outline() { + // A clip group, clipping path A, holding a clip group whose clipping path B shows a small + // rectangle and hides a line that runs along A's bottom edge. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("nested clips", |d, sel| { + let mut path = |p: PathData| Arc::new(Node::path(d.alloc_id(), p, Appearance::default())); + let mut l = BezPath::new(); + l.move_to((0.0, 200.0)); + l.line_to((400.0, 200.0)); + let (b, shown, hidden) = ( + path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0))), + path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0))), + path(PathData::from_bezpath(&l)), + ); + let a = path(shapes::rectangle(Rect::new(50.0, 50.0, 300.0, 200.0))); + let clipped = |mut g: Node| { + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + g + }; + let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, hidden]))); + let outer = clipped(Node::group(d.alloc_id(), vec![a, inner])); + let id = outer.id; + d.insert(parent, usize::MAX, outer)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let (_, g) = weed(&mut s, json!({"margin": 10, "count": 0})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 100.0, 150.0, 140.0)), "only the shown rectangle is measured"); +} + +#[test] +fn art_a_nested_clip_hides_counts_toward_the_clip_limit() { + // A clip group, clipping path A of 20,000 segments, holding a clip group whose small clipping + // path B shows a rectangle and hides a closed zigzag C of 20,000 points across every row of A. + // The inner group shows four outline pieces, but testing each piece of A for being inside it + // looks at all of C. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("nested clips", |d, sel| { + let mut path = |p: BezPath| Arc::new(Node::path(d.alloc_id(), PathData::from_bezpath(&p), Appearance::default())); + let mut a = BezPath::new(); + a.move_to((0.0, 0.0)); + for i in 1..=20_000 { + a.line_to((f64::from(i) * 0.02, if i % 2 == 0 { 0.0 } else { 0.5 })); + } + a.line_to((400.0, 300.0)); + a.line_to((0.0, 300.0)); + a.close_path(); + let mut c = BezPath::new(); + c.move_to((300.0, 0.0)); + for i in 1..=20_000 { + c.line_to((300.0 + f64::from(i) * 0.002, if i % 2 == 0 { 0.0 } else { 300.0 })); + } + c.close_path(); + let b = path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)).to_bezpath()); + let shown = path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0)).to_bezpath()); + let (a, c) = (path(a), path(c)); + let clipped = |mut g: Node| { + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + g + }; + let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, c]))); + let outer = clipped(Node::group(d.alloc_id(), vec![a, inner])); + let id = outer.id; + d.insert(parent, usize::MAX, outer)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"margin": 10, "count": 0})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + +#[test] +fn art_selected_inside_a_clip_group_is_measured_by_what_shows() { + // The rectangle runs far outside the clipping path; selected on its own, it still shows only + // inside it. + let mut s = session(); + let clip = shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)); + let g = clip_group(&mut s, clip, shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0))); + let art = node(&s, g).children().unwrap()[1].id; + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&w), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]); + // The clipping path selected on its own is measured by the art it shows. + let clip_path = node(&s, g).children().unwrap()[0].id; + select(&mut s, &[clip_path]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + // With the art selected too, or the group, the group is measured once. + let d = &s.doc().unwrap().doc; + let outline = |ids: &[NodeId]| super::geometry::Reader::new(d).read_ids(ids).unwrap().unwrap().outline().unwrap().len(); + assert_eq!(outline(&[clip_path, art]), outline(&[g])); + assert_eq!(outline(&[g, art, g]), outline(&[g])); +} + +#[test] +fn art_in_a_hidden_group_is_left_out() { + let mut s = session(); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let b = rect(&mut s, 200.0, 100.0, 50.0, 40.0); + select(&mut s, &[a, b]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(g) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[a]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "nothing selected shows"); + select(&mut s, &[a, shown]); + let (_, w) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + +#[test] +fn art_in_a_hidden_group_is_not_measured() { + // Selected art in a hidden group with more outline points than the limit doesn't count toward + // it. + let mut s = session(); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let mut p = BezPath::new(); + p.move_to((100.0, 100.0)); + for i in 1..=super::geometry::MAX_POINTS as u32 { + p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + let dense = insert(&mut s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(g) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[dense, shown]); + let (_, w) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + +#[test] +fn clip_outlines_crossing_too_often_are_refused() { + // A clipping path whose 700 edges each run across the art, and open art whose 700 segments + // each run down across the clipping path: every pair crosses, and each of the 490,000 pieces + // of art between crossings would be tested against all 700 edges. + let zigzag = |across: bool| { + let mut p = BezPath::new(); + for i in 0..=700 { + let (along, side) = (100.0 + f64::from(i) * 0.25, if i % 2 == 0 { 100.0 } else { 300.0 }); + let pt = if across { (side, along) } else { (along, side) }; + if i == 0 { p.move_to(pt) } else { p.line_to(pt) } + } + p + }; + let mut clip = zigzag(true); + clip.close_path(); + let mut s = session(); + clip_group(&mut s, PathData::from_bezpath(&clip), PathData::from_bezpath(&zigzag(false))); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + +/// Each weeding line's stretches inside the art, found from the crossings indexed once for all the +/// lines, match those found by measuring the whole art along that line. +#[test] +fn indexed_crossings_find_the_same_stretches() { + let mut s = session(); + let mut ids = vec![]; + let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0); + select(&mut s, &[outer, inner]); + ids.push(id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap())); + let big = rect(&mut s, 220.0, 50.0, 200.0, 200.0); + let window = id_of(&s.execute("shape.ellipse", &json!({"x": 260, "y": 80, "width": 90, "height": 120})).unwrap()); + select(&mut s, &[big, window]); + ids.push(id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap())); + ids.push(id_of(&s.execute("text.create", &json!({"x": 100, "y": 320, "text": "WEED 8", "size": 60})).unwrap())); + ids.push(line(&mut s, &[(90.0, 40.0), (450.0, 380.0)])); + let a = rect(&mut s, 300.0, 300.0, 30.0, 30.0); + let b = id_of(&s.execute("shape.ellipse", &json!({"x": 320, "y": 310, "width": 60, "height": 40})).unwrap()); + select(&mut s, &[a, b]); + ids.push(id_of(&s.execute("object.group", &json!({})).unwrap())); + let d = &s.doc().unwrap().doc; + let nodes: Vec<&Node> = ids.iter().filter_map(|id| d.node(*id)).collect(); + let region = super::geometry::Reader::new(d).read(&nodes).unwrap().unwrap(); + let (layout, _) = super::geometry::Layout::of(®ion); + let count = 97; + for vertical in [false, true] { + let (v0, v1) = (30.0, 400.0); + let step = (v1 - v0) / (count as f64 + 1.0); + let crossed = super::geometry::crossings(®ion, vertical, v0, step, count).unwrap(); + assert_eq!(crossed.len(), count); + let mut hit = 0; + for (k, line) in crossed.iter().enumerate() { + let c = v0 + step * (k as f64 + 1.0); + let want = region.spans(vertical, c); + hit += usize::from(!want.is_empty()); + assert_eq!(region.spans_on(&layout, line, 0), want, "line {k}, vertical {vertical}"); + } + assert!(hit > count / 2, "most lines cross the art ({hit})"); + } +} + +#[test] +fn art_nested_too_deep_is_refused() { + // A rectangle inside `levels` groups, each holding the next. A document keeps none this deep, + // but the art symbol instances and live objects draw nests further. + let doc = vectorcraft_doc::Document::new(800.0, 600.0); + let nested = |levels: usize| { + let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default()); + for i in 0..levels { + n = Node::group(NodeId(i as u64 + 2), vec![Arc::new(n)]); + } + n + }; + let read = |n: &Node| super::geometry::Reader::new(&doc).read(&[n]); + assert!(read(&nested(super::geometry::MAX_DEPTH)).unwrap().is_some()); + let e = read(&nested(super::geometry::MAX_DEPTH + 1)).unwrap_err(); + assert!(e.contains("nested more than"), "{e}"); +} + +#[test] +fn spacing_divides_the_box_into_strips_no_wider() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 10.0, 40.0); + // The box is 60 pt tall: three strips of 20 pt. + let (_, g) = weed(&mut s, json!({"margin": 10, "spacing": 25, "direction": "horizontal", "box": false})); + assert_eq!(weed_box(&g), None); + let ys: Vec = lines(&g).iter().map(|l| l.0.1).collect(); + assert_eq!(ys, [110.0, 110.0, 130.0, 130.0]); + let mut s = session(); + rect(&mut s, 100.0, 100.0, 10.0, 40.0); + assert!(s.execute("addon.cutting.weed", &json!({"spacing": 0.01})).is_err(), "too many lines"); +} + +#[test] +fn the_lines_take_a_swatch() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Cut", "color": "cmyk 0 100 0 0", "spot": true})).unwrap(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let (_, g) = weed(&mut s, json!({"swatch": "Cut", "weight": 1})); + for c in g.children().unwrap() { + match c.appearance.stroke_paint() { + Paint::Solid { swatch, .. } => assert_eq!(swatch.as_deref(), Some("Cut")), + other => panic!("stroke {other:?}"), + } + assert_eq!(c.appearance.stroke_width(), 1.0); + } +} + +/// Adds the object `make` makes with a new id to the current layer, selects it and gives its id. +fn insert(s: &mut Session, make: impl FnOnce(NodeId) -> Node) -> NodeId { + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("insert", |d, _| { + let n = make(d.alloc_id()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + Ok(id) + }) + .unwrap(); + select(s, &[id]); + id +} + +/// A 100 pt square turned 45° about (300, 300): a diamond 141.42 pt across. +fn diamond_xf() -> Affine { + Affine::translate((300.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4) * Affine::translate((-50.0, -50.0)) +} + +/// The weeding lines of `g` at height `y` (to 1/1000 pt). +fn at(g: &Node, y: f64) -> Vec<(f64, f64)> { + lines(g).into_iter().filter(|((_, y0), (_, y1))| *y0 == y && *y1 == y).map(|((x0, _), (x1, _))| (x0, x1)).collect() +} + +/// The diamond's weeding with three horizontal lines and a margin of 10: the box, and where the +/// first line's left piece ends. +fn diamond_weeding(s: &mut Session) -> (Rect, f64) { + let (_, g) = weed(s, json!({"margin": 10, "count": 3, "direction": "horizontal"})); + let b = weed_box(&g).unwrap(); + let y = ((b.y0 + b.height() / 4.0) * 1000.0).round() / 1000.0; + let first = at(&g, y); + assert_eq!(first.len(), 2, "{first:?}"); + (b, first[0].1) +} + +#[test] +fn an_image_is_measured_by_its_frame_as_turned() { + let mut s = session(); + let im = vectorcraft_doc::ImageObject { + key: "image".into(), + width: 100, + height: 100, + xf: diamond_xf(), + link: None, + placement: Default::default(), + pixels: None, + }; + insert(&mut s, |id| Node::new(id, NodeKind::Image(Box::new(im)))); + let (b, end) = diamond_weeding(&mut s); + let half = 50.0 * std::f64::consts::SQRT_2; + assert!((b.x0 - (290.0 - half)).abs() < 1e-3 && (b.y1 - (310.0 + half)).abs() < 1e-3, "{b:?}"); + // A quarter of the box down, the line meets the diamond's upper left edge, not its bounds. + let y = b.y0 + b.height() / 4.0; + assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}"); +} + +#[test] +fn a_placed_file_is_measured_by_the_art_it_shows() { + let dir = crate::tests_links::Folder::new("weed-placed"); + // A file with a 60 × 40 artboard and a 20 pt wide rectangle at each side (hidden when + // `hidden`), placed centered on (200, 150) and selected. + let place = |s: &mut Session, name: &str, hidden: bool| { + let src = dir.file(name); + let mut d = vectorcraft_doc::Document::new(60.0, 40.0); + let layer = d.layers[0].id; + for x in [0.0, 40.0] { + let id = d.alloc_id(); + let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0); + let mut n = Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 40.0)), look); + n.visible = !hidden; + d.insert(Some(layer), 0, n).unwrap(); + } + crate::tests_links::write(&src, &vectorcraft_format::save_file(&d)); + let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap(); + select(s, &[NodeId(r["ids"][0].as_u64().unwrap())]); + }; + let mut s = session(); + place(&mut s, "pair.vectorcraft", false); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The rectangles lie at 170..190 and 210..230; the line runs between them too. + assert_eq!(at(&g, 150.0), [(160.0, 170.0), (190.0, 210.0), (230.0, 240.0)]); + // A file read but shown in a box with no size shows nothing, and its box isn't measured. + let id = NodeId(s.execute("file.place", &json!({"path": dir.file("pair.vectorcraft"), "at": [400, 150]})).unwrap()["ids"][0].as_u64().unwrap()); + s.edit("no size", |d, _| { + if let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) { + (p.width, p.height) = (0.0, 0.0); + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[id]); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + // A file read with nothing showing has no outlines, and its box isn't measured instead. + let mut s = session(); + place(&mut s, "hidden.vectorcraft", true); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + // A file whose art is an instance of a symbol of its own: the instance's art is measured. + let src = dir.file("symbol.vectorcraft"); + let mut d = vectorcraft_doc::Document::new(60.0, 40.0); + let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0); + let art = Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 40.0)), look); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(art) }); + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY })).unwrap(); + crate::tests_links::write(&src, &vectorcraft_format::save_file(&d)); + let mut s = session(); + let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap(); + select(&mut s, &[NodeId(r["ids"][0].as_u64().unwrap())]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + let b = weed_box(&g).unwrap(); + assert!(b.x0 >= 160.0 - 1e-6 && b.x1 <= 240.0 + 1e-6 && b.width() < 41.0, "the bar, not the whole box: {b:?}"); +} + +#[test] +fn a_placed_file_that_cant_be_read_is_measured_by_its_box_as_turned() { + let mut s = session(); + let p = vectorcraft_doc::PlacedDocument { + link: vectorcraft_doc::LinkInfo::new("/no/such/file.pdf"), + key: "missing".into(), + bounding: false, + page_box: Default::default(), + width: 100.0, + height: 100.0, + xf: diamond_xf(), + placement: Default::default(), + }; + insert(&mut s, |id| Node::new(id, NodeKind::PlacedDocument(Box::new(p)))); + let (b, end) = diamond_weeding(&mut s); + let half = 50.0 * std::f64::consts::SQRT_2; + let y = b.y0 + b.height() / 4.0; + assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}"); +} + +#[test] +fn a_symbol_instance_is_measured_by_its_art() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0); + let b = rect(&mut s, 140.0, 100.0, 20.0, 40.0); + select(&mut s, &[a, b]); + let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap()); + select(&mut s, &[inst]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The line runs between the two rectangles too. + assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 140.0), (160.0, 170.0)]); + // An instance of a symbol the document doesn't have draws nothing, and has no outlines. + s.edit("lose the symbol", |d, _| { + if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(inst).map(|n| &mut n.kind) { + *symbol = "Missing".into(); + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[inst]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + select(&mut s, &[inst, shown]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + +#[test] +fn a_blend_is_measured_by_the_art_it_draws() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0); + let b = rect(&mut s, 200.0, 100.0, 20.0, 40.0); + let blend = id_of(&s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap()); + select(&mut s, &[blend]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The step halfway between the two rectangles stops the line too. + assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 150.0), (170.0, 200.0), (220.0, 230.0)]); +} + +#[test] +fn the_group_goes_on_top_of_the_current_layer_and_hidden_layers_are_left_out() { + let mut s = session(); + let first = s.doc().unwrap().doc.layers[0].id; + let hidden = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap()); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let below = rect(&mut s, 400.0, 400.0, 20.0, 20.0); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(first) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[hidden]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "art in a hidden layer doesn't show"); + select(&mut s, &[hidden, shown]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); + let d = &s.doc().unwrap().doc; + assert_eq!(d.parent_of(g.id), Some(art)); + assert_eq!(d.node(art).unwrap().children().unwrap().last().map(|c| c.id), Some(g.id), "on top"); + // A hidden object selected on its own shows nothing. + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(below) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[below]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "a hidden object doesn't show"); +} + +#[test] +fn the_group_goes_outside_a_clip_group_edited_in_isolation() { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id; + let g = clip_group(&mut s, shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0))); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + let art = node(&s, g).children().unwrap()[1].id; + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The clipping path would hide the lines and the box around the art inside it. + let d = &s.doc().unwrap().doc; + assert_eq!(d.parent_of(w.id), Some(layer)); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + // A plain group edited in isolation inside a clip group: the group goes outside the clip group. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let (inner, art) = s + .edit("clip group around a group", |d, _| { + let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default())); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)), Appearance::default()); + let art_id = art.id; + let inner = Node::group(d.alloc_id(), vec![Arc::new(art)]); + let inner_id = inner.id; + let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(inner)]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + d.insert(parent, usize::MAX, g)?; + Ok((inner_id, art_id)) + }) + .unwrap(); + s.execute("object.isolate", &json!({"id": inner.0})).unwrap(); + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(s.doc().unwrap().doc.parent_of(w.id), parent); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); +} + +/// Adds one path of `count` open vertical subpaths 2 pt apart, from (100, 100) down to y = 200. +fn bars(s: &mut Session, count: u32) -> NodeId { + let mut p = BezPath::new(); + for i in 0..count { + let x = 100.0 + f64::from(i) * 2.0; + p.move_to((x, 100.0)); + p.line_to((x, 200.0)); + } + insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())) +} + +#[test] +fn more_than_the_most_pieces_of_line_is_refused() { + // 1,000 lines each cut into 100 pieces by 101 bars: as many as one run adds. + let mut s = session(); + bars(&mut s, 101); + let r = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap(); + assert_eq!(r["lines"], json!(super::MAX_PIECES)); + // One bar more makes 1,000 pieces more. + let mut s = session(); + bars(&mut s, 102); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap_err(); + assert!(e.to_string().contains("more than 100000 pieces"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + +#[test] +fn art_with_more_than_the_most_outline_points_is_refused() { + // An open zigzag of `points` points: one subpath, measured by its points. + let zigzag = |s: &mut Session, points: u32| { + let mut p = BezPath::new(); + p.move_to((100.0, 100.0)); + for i in 1..points { + p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())); + }; + let mut s = session(); + zigzag(&mut s, super::geometry::MAX_POINTS as u32); + s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap(); + let mut s = session(); + zigzag(&mut s, super::geometry::MAX_POINTS as u32 + 1); + let e = s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap_err(); + assert!(e.to_string().contains("too many outline points"), "{e}"); +} + +#[test] +fn bad_params_and_an_empty_selection_are_refused() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + for p in [ + json!({"margin": -1}), + json!({"gap": "x"}), + json!({"count": 1.5}), + json!({"count": 1001}), + json!({"count": 2, "spacing": 10}), + json!({"spacing": 0}), + json!({"direction": "diagonal"}), + json!({"box": "yes"}), + json!({"weight": 0}), + json!({"swatch": "no such swatch"}), + json!({"swatch": 3}), + json!({"box": false, "count": 0}), + ] { + let n = s.doc().unwrap().doc.node_count(); + assert!(s.execute("addon.cutting.weed", &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing added for {p}"); + } + // The same art with good params is weeded; with nothing selected it is refused. + s.execute("addon.cutting.weed", &json!({"count": 2, "gap": "1 mm"})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err()); +} diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index fb8af2b11e..76b4e1a715 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure|tilt|bearing|rotation, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest; tilt: width with the pen lying flat to width_max upright; bearing and rotation: width at −180° to width_max at 180°; half way on a stroke drawn without that input), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index da7dc42ece..9ec21b6541 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -274,14 +274,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})) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4f3dc3403a..b7affffa81 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 61eac58f37..5754f891cc 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}; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ec1d7e36ba..8c9876054a 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -1019,13 +1019,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..01ef3f5082 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 { @@ -313,7 +317,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 +915,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); } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 593d325460..43d9a8e05f 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", + "{x?, y?, width?, height? (each more than 0 and at most 3,999,856 pt, as file.new takes them), name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", has_doc, artboard_new ), @@ -926,6 +926,9 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { let guide_label = artboard_guide_label(p, "artboard.new")?.flatten(); check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?; + if ["width", "height"].iter().filter_map(|k| p.get(*k).and_then(Value::as_f64)).any(|side| !super::newdoc::side_in_range(side)) { + return Err(super::newdoc::side_error("artboard.new", d0.units)); + } let r = match artboard_side(p, "artboard.new")? { Some(side) => { if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { @@ -941,6 +944,10 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { 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), }; + // 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()) { + return Err(super::newdoc::side_error("artboard.new", d0.units)); + } let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty()); let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false)); let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false); diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index bd15d748ca..58c7974460 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -241,8 +241,8 @@ impl DocSettings { if let Some(h) = length("height", self.units)? { self.height = h; } - if !(self.width > 0.0 && self.height > 0.0) || self.width > MAX_SIDE || self.height > MAX_SIDE { - return Err(bad(cmd, "width/height must be positive and within the canvas")); + if !side_in_range(self.width) || !side_in_range(self.height) { + return Err(side_error(cmd, self.units)); } match p.get("orientation").and_then(Value::as_str) { None => {} @@ -359,8 +359,29 @@ impl DocSettings { } } -/// Largest artboard side `file.new` accepts, points. -const MAX_SIDE: f64 = 16383.0 * 10.0; +/// Largest artboard side `file.new` and `artboard.new` accept, points: the span of the canvas +/// ([`crate::MAX_COORD`] from the origin) less the largest bleed on both sides, 3,999,856 pt. +pub(crate) const MAX_SIDE: f64 = crate::MAX_COORD - 2.0 * vectorcraft_doc::setup::MAX_BLEED; + +/// Is `side` an artboard side `file.new` and `artboard.new` accept: more than 0 and at most [`MAX_SIDE`]? +pub(crate) fn side_in_range(side: f64) -> bool { + side > 0.0 && side <= MAX_SIDE +} + +/// The error of an artboard side outside [`side_in_range`]: the limit in points, and in `units` as +/// well when they aren't points, rounded down to the decimals [`Unit::number`] shows so the value +/// shown is one the commands take. +pub(crate) fn side_error(cmd: &str, units: Unit) -> crate::EngineError { + let limit = match units { + Unit::Points => format!("{MAX_SIDE} pt"), + u => { + let step = if matches!(u, Unit::Pixels | Unit::Picas | Unit::Q | Unit::Ha) { 1e3 } else { 1e4 }; + let shown = (u.from_pt(MAX_SIDE) * step).floor() / step; + format!("{MAX_SIDE} pt ({} {})", u.number(u.to_pt(shown)), u.suffix()) + } + }; + bad(cmd, format!("width and height must be more than 0 and at most {limit}")) +} /// Largest artboard spacing, points. const MAX_SPACING: f64 = 1.0e5; diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 060791972f..94f865e224 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -33,6 +33,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.swatchLegend", "addon.contour.set", "addon.cutting.bridges", + "addon.cutting.weed", "addon.image.removeBackground", "addon.pathtools.addPoints", "addon.pathtools.close", 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..f3f2c68764 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 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\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; 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)} 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, 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 ), @@ -58,7 +58,7 @@ pub fn specs() -> Vec { "Glyphs", [], None, - "{id, start: byte (a character of it), gid: glyph id|null} draw that one character with another glyph of its font as one undo step, what a double-click in the Glyphs panel's Show › Alternates for Current Selection does: gid is one of the glyphs text.glyphAlternates lists for it; its own default glyph, or null, puts the default glyph back. The text keeps the character, and its other attributes do not change. The choice is saved with the character's style and names the face it was made in (PostScript name and version): set in another font or version, or after the font goes missing, the character takes its default glyph; typed characters take their default glyphs. Choosing the glyph the character already draws is not an edit. An error when the character's font is not available, the glyph is not one of its alternates, or the character's style does not draw it (All Caps, Small Caps, another numeral system) → {id, start, end (the character's bytes), gid (the glyph drawn)}", + "{id, start: byte (a character of it), gid: glyph id|null} draw that one character with another glyph of its font as one undo step, what a double-click in the Glyphs panel's Show › Alternates for Current Selection does: gid is one of the glyphs text.glyphAlternates lists for it, a glyph no character maps and no feature reaches whose name is not another letter's (`a.salt` for a), its own default glyph, or null, puts the default glyph back. The text keeps the character, and its other attributes do not change. The choice is saved with the character's style and names the face it was made in (PostScript name and version): set in another font or version, or after the font goes missing, the character takes its default glyph; typed characters take their default glyphs. Choosing the glyph the character already draws is not an edit. An error when the character's font is not available, the glyph is not one of those, or the character's style does not draw it (All Caps, Small Caps, another numeral system) → {id, start, end (the character's bytes), gid (the glyph drawn)}", has_doc, set_glyph_alternate ), @@ -466,6 +466,8 @@ pub(crate) struct CharChange { language: Option>, font_axes: Option, font_height_reference: Option, + /// Take away the glyph choice ([`CharStyle::glyph_alternate`]) of the characters (`glyphAlternate: null`). + clear_glyph_alternate: bool, } /// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None` @@ -567,7 +569,8 @@ pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) { } } -/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). +/// `features: ["dlig", "-liga", "salt=2", …]` → the canonical tag list (differences from the +/// defaults; `salt=1` is written `salt`, `salt=0` and `-salt` leave it out). pub(crate) fn features_param(p: &Value, cmd: &str) -> Result>> { let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) }; let not_tags = || bad(cmd, "features must be a list of tags"); @@ -576,7 +579,8 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> return Err(bad( cmd, format!( - "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; prefix - to turn off)" + "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {}, as `salt=2`)", + vectorcraft_text::MAX_FEATURE_VALUE ), )); } @@ -586,9 +590,9 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> /// The most entries a `featureChanges` list may have. const MAX_FEATURE_CHANGES: usize = 256; -/// A feature tag without its `-` or `+` prefix. +/// A feature tag without its `-` or `+` prefix and its `=N` value. fn feature_name(tag: &str) -> &str { - tag.strip_prefix(['-', '+']).unwrap_or(tag) + vectorcraft_text::OtFeatures::tag_name(tag) } /// `featureChanges: ["tnum", "-onum", …]` → the features to turn on (or, with `-`, off) on top of @@ -756,6 +760,13 @@ impl CharChange { language: language_param(p, cmd)?, font_axes: font_axes_param(p, cmd)?, font_height_reference: font_height_reference_param(p, cmd)?, + clear_glyph_alternate: match p.get("glyphAlternate") { + None => false, + Some(Value::Null) => true, + Some(_) => { + return Err(bad(cmd, "`glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one)")); + } + }, }; if c.size.is_some_and(|v| v <= 0.0) { return Err(bad(cmd, "size must be positive")); @@ -809,6 +820,7 @@ impl CharChange { && self.language.is_none() && self.font_axes.is_none() && self.font_height_reference.is_none() + && !self.clear_glyph_alternate } pub(crate) fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> { @@ -951,6 +963,9 @@ impl CharChange { if let Some(v) = &self.language { st.language.clone_from(v); } + if self.clear_glyph_alternate { + st.glyph_alternate = None; + } } } @@ -1269,7 +1284,9 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { }; let t = text_ref(s, id)?; let (ch, bytes, face) = glyph_target(&t, p, C)?; - let default = face.glyph_for(ch); + let db = vectorcraft_text::FontDb::global(); + let context = std::cell::OnceCell::new(); + let contextual = || context.get_or_init(|| vectorcraft_text::alternates_in_context(db, &t, bytes.start)); let holder = t .runs .iter() @@ -1290,6 +1307,16 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { let mut probe = t.clone(); edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None); let probe_lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); + // Choices belong to the supplying face, including fallback for a missing grapheme. When the + // character overflows, the contextual probe finds that face in an unconstrained frame. + let face = probe_lay + .glyphs + .iter() + .find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len)) + .and_then(|g| db.face_by_id(g.font_id)) + .or_else(|| contextual().as_ref().and_then(|f| db.face_by_id(f.font_id))) + .unwrap_or(face); + let default = face.glyph_for(ch); let unchosen = holder .as_ref() .filter(|_| !styled) @@ -1297,7 +1324,14 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { let substituted = unchosen.is_some_and(|g| g != default); let choice = match gid.filter(|g| *g != default || substituted) { None => None, - Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)), + Some(g) + if g == default + || face.alternates(ch).iter().any(|a| a.gid == g) + || face.is_unlisted_alternate(ch, g) + || contextual().as_ref().is_some_and(|f| f.font_id == face.id() && f.alternates.iter().any(|a| a.gid == g)) => + { + Some(face.choice(ch, g)) + } Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))), }; // Without a choice, the glyph drawn in whatever face draws the character, as diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 77ecd9d786..74544058cf 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -309,12 +309,12 @@ fn character_style_color_and_features_reach_text_and_follow_swatch_edits() { // The command takes the color of the link at the tint, whatever color is sent. tinted["tint"] = json!(0.4); let attrs = json!({"fill": brand, "stroke": tinted, "stroke_width": 2.0, "overprint_fill": true, "overprint_stroke": false, - "features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]}); + "features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]}); s.execute("charStyle.new", &json!({"name": "Ink", "attrs": attrs})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Ink", "id": id})).unwrap(); let want_stroke = s.doc().unwrap().doc.tint_paint("Brand", 0.4).unwrap(); assert_eq!(run_paints(&s, id), (brand, want_stroke.clone(), 2.0, true, false)); - assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]); + assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]); let def = |s: &Session| s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Ink").unwrap().attrs.clone(); assert_eq!(def(&s)["stroke"], serde_json::to_value(&want_stroke).unwrap()); let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); @@ -355,6 +355,8 @@ fn character_style_color_and_features_are_checked() { (json!({"stroke_width": 5.0e6}), "`stroke_width` must be from 0 to 4000000"), (json!({"features": ["dlig", "zzzz"]}), "unknown OpenType feature `zzzz`"), (json!({"features": ["--liga"]}), "unknown OpenType feature `--liga`"), + (json!({"features": ["salt=x"]}), "unknown OpenType feature `salt=x`"), + (json!({"features": ["-salt=2"]}), "unknown OpenType feature `-salt=2`"), (json!({"features": many}), "features lists at most 256 tags"), (json!({"fill": {"type": "solid"}}), "missing field"), ] { diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 0328667d82..e71267a2de 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\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; 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, 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 ), 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/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/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/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/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index d0901f30a0..ae8ac0c4f0 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()); diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 4bc58e5f6b..0b573a96f3 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -233,7 +233,7 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { saved, [ (1.0, 1.0, 1.0, 0.0, vec![0.0, 0.0, 0.0, 9.0]), - (163830.0, 163830.0, 1.0, 1.0e5, vec![72.0, 72.0, 72.0, 0.0]), + (3_999_856.0, 200000.0, 1.0, 1.0e5, vec![72.0, 72.0, 72.0, 0.0]), (10.0, 10.0, 1000.0, 0.0, vec![0.0; 4]), ] ); @@ -242,7 +242,7 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { s.execute("file.new", &json!({"preset": "Low"})).unwrap(); assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 1.0, 1.0)], [0.0, 0.0, 0.0, 9.0])); s.execute("file.new", &json!({"preset": "High"})).unwrap(); - assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 163830.0, 163830.0)], [72.0, 72.0, 72.0, 0.0])); + assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 3_999_856.0, 200000.0)], [72.0, 72.0, 72.0, 0.0])); s.execute("file.new", &json!({"preset": "Many"})).unwrap(); assert_eq!(boards(&s).len(), 1000); let custom = presets(&mut s, "Recent").into_iter().find(|p| p["name"] == "Custom").unwrap(); @@ -250,6 +250,49 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { assert_eq!(boards(&s).iter().map(|r| r.x0).collect::>(), [0.0, 100.0, 200.0], "side by side, not overlapping"); } +/// `file.new` and `artboard.new` take the same largest artboard side (the canvas less the largest +/// bleed on both sides) and refuse a side just past it, or of 0 or less, with an error that names +/// the limit in points and, when the document's units aren't points, in those units too. +#[test] +fn file_new_and_artboard_new_take_the_same_largest_side() { + let max = 3_999_856.0; + let in_meters = "at most 3999856 pt (1411.0603 m)"; + let s = new_doc(json!({"width": max, "height": 170078})); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, max, 170078.0)]); + let mut s = Session::new(); + let err = s.execute("file.new", &json!({"width": max + 1.0, "height": 100})).unwrap_err().to_string(); + assert!(err.contains("width and height must be more than 0 and at most 3999856 pt") && !err.contains('('), "{err}"); + let err = s.execute("file.new", &json!({"width": 100, "height": "1412 m", "units": "Meters"})).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{err}"); + // The value in other units is rounded down, so it is one file.new takes. + let err = s.execute("file.new", &json!({"width": 100, "height": "56000 in", "units": "Inches"})).unwrap_err().to_string(); + assert!(err.contains("at most 3999856 pt (55553.5555 in)"), "{err}"); + assert!(s.execute("file.new", &json!({"width": 0, "height": 100})).is_err()); + assert!(s.doc().is_err(), "no document was made"); + s.execute("file.new", &json!({"width": "55553.5555 in", "height": 100, "units": "Inches"})).unwrap(); + + let mut s = new_doc(json!({"units": "Meters"})); + s.execute("artboard.new", &json!({"x": -2_000_000, "y": 0, "width": max, "height": 100})).unwrap(); + assert_eq!(boards(&s)[1], Rect::new(-2_000_000.0, 0.0, max - 2_000_000.0, 100.0)); + for p in [ + json!({"x": -2_000_000, "y": 0, "width": max + 1.0, "height": 100}), + json!({"x": 0, "y": 2000, "width": 100, "height": max + 1.0}), + json!({"width": 0}), + json!({"height": -1}), + ] { + let err = s.execute("artboard.new", &p).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{p}: {err}"); + } + assert_eq!(boards(&s).len(), 2, "the refused artboards were not added"); + // A size taken from a wider artboard is refused too. + s.execute("artboard.setProps", &json!({"index": 1, "x": -2_000_000, "y": 0, "width": max + 1.0, "height": 100})).unwrap(); + for p in [json!({}), json!({"x": 0, "y": 5000, "height": 100}), json!({"side": "bottom", "beside": 1})] { + let err = s.execute("artboard.new", &p).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{p}: {err}"); + } + assert_eq!(boards(&s).len(), 2); +} + #[test] fn saved_presets_live_in_the_preferences() { let mut s = Session::new(); diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 9932055652..336382107b 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -569,6 +569,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/textedit.rs b/crates/engine/src/tests/textedit.rs index abb39c0d96..46d41adaea 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -139,6 +139,54 @@ fn feature_changes_keep_each_runs_other_features() { assert_eq!(features(&s), [vec!["smcp", "numr", "liga", "dnom"], vec!["swsh", "numr", "liga", "dnom"], vec!["-onum", "swsh", "dnom"]]); } +/// A feature with a value (`salt=2`): `features` and `featureChanges` take it, the next value +/// replaces it, `salt=1` is a plain `salt` and `salt=0` and `-salt` are off, a value this build +/// does not know is an error, one undo step each, and the native format keeps the plain string. +#[test] +fn feature_values_are_set_replaced_and_saved() { + let mut s = session(); + let id = text(&mut s, "abcd"); + let features = |s: &Session| obj(s, id).runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect::>(); + let range = |s: &mut Session, p: Value| { + let mut p = p; + p["id"] = json!(id.0); + p["start"] = json!(1); + p["end"] = json!(2); + s.execute("text.setRangeStyle", &p).map(|_| ()) + }; + let undo = s.doc().unwrap().history.undo.len(); + range(&mut s, json!({"features": ["salt=2"]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(features(&s), [("a".into(), vec![]), ("b".into(), vec!["salt=2".to_string()]), ("cd".into(), vec![])]); + range(&mut s, json!({"features": ["salt=3"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["salt=3"], "the next value replaces it"); + range(&mut s, json!({"featureChanges": ["salt=2", "ss01"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "written in the canonical order"); + range(&mut s, json!({"featureChanges": ["salt=1"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt"], "salt=1 is a plain salt"); + range(&mut s, json!({"featureChanges": ["salt=2"]})).unwrap(); + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let back = vectorcraft_format::load(&saved).unwrap(); + let Some(NodeKind::Text(t)) = back.node(id).map(|n| &n.kind) else { panic!("the text survives") }; + assert_eq!(t.runs[1].style.features, ["ss01", "salt=2"], "the native format keeps the string"); + assert!(String::from_utf8_lossy(&saved).contains("\"salt=2\"")); + range(&mut s, json!({"featureChanges": ["-salt"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01"], "-salt turns it off"); + range(&mut s, json!({"featureChanges": ["salt=2"], "features": null})).unwrap(); + range(&mut s, json!({"featureChanges": ["salt=0"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01"], "salt=0 turns it off"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "undo takes it back"); + let before = features(&s); + for tag in ["salt=", "salt=x", "salt=65536", "-salt=2", "liga=2", "salt=2=3", "zzzz=2"] { + for key in ["features", "featureChanges"] { + let e = range(&mut s, json!({ key: [tag] })).unwrap_err().to_string(); + assert!(e.contains(&format!("`{tag}`")), "{key} {tag}: {e}"); + } + } + assert_eq!(features(&s), before, "the refused commands change nothing"); +} + /// `text.setStyle {stroke, strokeWidth}` sets the character stroke of all the text or of a range, as /// `text.setRangeStyle` does, and a call with `fill` and `stroke` sets both in one undo step. #[test] @@ -3166,6 +3214,41 @@ fn a_glyph_alternate_replaces_one_character_through_undo_and_reopen() { assert!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap_err().to_string().contains("is not available")); } +/// `glyphAlternate: null` in `text.setRangeStyle` takes a glyph choice away in the same undo step +/// as the other style it sets (a feature: what the Glyphs panel does for a variant a feature +/// draws); anything but null is refused. +#[test] +fn set_range_style_takes_a_glyph_choice_away_with_a_feature() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xay", "size": 20, "font": alternates_family(true)})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": u1})).unwrap(); + let gids = |s: &Session| vectorcraft_text::layout(db, &obj(s, id)).glyphs.iter().map(|g| g.gid).collect::>(); + assert_eq!(gids(&s)[1], u1); + let undo = s.doc().unwrap().history.undo.len(); + let e = s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": 5})).unwrap_err().to_string(); + assert!(e.contains("takes only null"), "{e}"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null, "featureChanges": ["ss01"]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone"); + assert_eq!( + gids(&s)[1], + face.substitutions(face.glyph_for('a')).iter().find(|x| &x.feature == b"ss01").map(|x| x.glyphs[0]).unwrap(), + "ss01 draws its glyph" + ); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(gids(&s)[1], u1, "undone: the choice again"); + // With nothing else to set it is still a change. + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null})).unwrap(); + assert_eq!(gids(&s)[1], face.glyph_for('a')); +} + /// The glyph already drawn, chosen again, and null for a character drawn with its default glyph /// leave the document and its undo history as they are. An empty run before the character does /// not stand in for the character's own choice. @@ -3558,6 +3641,89 @@ fn an_overflowing_grapheme_keeps_the_styles_of_its_characters() { assert_eq!(r["gid"], json!(g('b')), "the overflowing Ka is shaped apart from the nukta of another size"); } +/// A contextual alternate with no feature must name the fallback face that supplies the glyph, +/// including when the character is outside its frame. Neither another face's glyph nor an +/// out-of-range id becomes a choice. +#[test] +fn a_contextual_fallback_glyph_choice_draws_through_undo_and_reopen() { + use vectorcraft_text::test_fonts::{ + CONTEXTUAL_FALLBACK_CHAR as CH, CONTEXTUAL_FALLBACK_FAMILY, alternates_family, alternates_font, contextual_fallback_font, source_sans_gid, + }; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the primary test font builds")); + db.add_font(contextual_fallback_font().expect("the fallback test font builds")); + let primary = db.face(alternates_family(true), "Regular").expect("the primary face"); + let fallback = db.face(CONTEXTUAL_FALLBACK_FAMILY, "Regular").expect("the supplying face"); + let g = |c| u32::from(source_sans_gid(c).expect("a Source Sans glyph")); + let (default, alternate) = (g('m'), g('d')); + assert_eq!(primary.glyph_for(CH), 0); + assert_eq!(db.face_covering(CH).unwrap().id(), fallback.id(), "only the fixture supplies the private-use character"); + assert!(!fallback.alternates(CH).iter().any(|a| a.gid == alternate), "the alternate belongs to its shaped, not nominal, glyph"); + assert!(!fallback.is_unlisted_alternate(CH, alternate), "the contextual validation path is required"); + let text = format!("a{CH}a"); + let start = 1; + let end = start + CH.len_utf8(); + let drawn = |t: &TextObject| { + let mut open = t.clone(); + open.kind = TextKind::Point; + vectorcraft_text::layout(db, &open).glyphs.into_iter().find(|g| g.byte == start).map(|g| (g.font_id, g.gid)) + }; + let state = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().revision); + + for overflow in [false, true] { + let mut s = session(); + let mut spec = json!({"x": 0, "y": 0, "text": text, "size": 20, "font": alternates_family(true)}); + if overflow { + spec["area"] = json!({"width": 200, "height": 2}); + } + let id = NodeId(s.execute("text.create", &spec).unwrap()["id"].as_u64().unwrap()); + let before = obj(&s, id); + assert_eq!(drawn(&before), Some((fallback.id(), default))); + if overflow { + assert!(vectorcraft_text::layout(db, &before).glyphs.is_empty(), "the original frame overflows"); + } + let contextual = vectorcraft_text::alternates_in_context(db, &before, start).expect("the panel's contextual alternates"); + assert_eq!((contextual.font_id, contextual.default), (fallback.id(), default)); + assert!(contextual.alternates.iter().any(|a| a.gid == alternate && a.apply.is_none())); + + let unchanged = state(&s); + for gid in [primary.glyph_for('a'), fallback.glyph_count()] { + let err = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": gid})).unwrap_err(); + assert!(err.to_string().contains("is not an alternate"), "{err}"); + assert_eq!(state(&s), unchanged, "a refused choice makes no edit"); + assert_eq!(obj(&s, id).runs, before.runs); + } + let result = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": alternate})).unwrap(); + assert_eq!(result, json!({"id": id.0, "start": start, "end": end, "gid": alternate})); + assert_eq!(state(&s).0, unchanged.0 + 1, "one undo step"); + let chosen = obj(&s, id); + assert_eq!(chosen.plain_text(), text); + assert_eq!(drawn(&chosen), Some((fallback.id(), alternate))); + let choice = chosen.runs.iter().find_map(|r| r.style.glyph_alternate.as_ref()).expect("the stored choice"); + assert_eq!(choice, &fallback.choice(CH, alternate), "the choice keeps the supplying face's identity"); + + let mut wrong_face = chosen.clone(); + wrong_face.runs.iter_mut().find(|r| r.style.glyph_alternate.is_some()).unwrap().style.glyph_alternate = Some(primary.choice(CH, alternate)); + assert_eq!(drawn(&wrong_face), Some((fallback.id(), default)), "another face's choice is not drawn"); + + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let reopened = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(reopened) = &reopened.node(id).unwrap().kind else { panic!("text missing") }; + assert_eq!(drawn(reopened), Some((fallback.id(), alternate)), "native restore retains the face and glyph"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, id).runs, before.runs, "Undo restores the original runs"); + assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), default))); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), alternate)), "Redo restores the choice"); + + let result = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": null})).unwrap(); + assert_eq!(result["gid"], json!(default), "clearing reports the fallback's shaped glyph"); + assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), default))); + assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none())); + } +} + #[test] fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() { use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; 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..c84921f6de 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. diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index d607686b83..6577754de0 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -893,6 +893,12 @@ fn structured_junk() { ("addon.pen.reshape", json!({"id": 2, "segment": 0, "t": "x", "dx": 1, "dy": 1, "mode": "symmetric"})), ("addon.pen.nextSegment", json!({"curved": "yes"})), ("addon.pen.closeJoin", json!({"ids": [u64::MAX]})), + // Cutting Tools add-on. + ("addon.cutting.weed", json!({"margin": 1e5, "count": 1000, "gap": 1e5, "direction": "both"})), + ("addon.cutting.weed", json!({"margin": -1, "count": 1.5, "spacing": 0, "gap": f64::MAX, "weight": 1e308})), + ("addon.cutting.weed", json!({"spacing": 1e-300, "box": "no", "direction": ["both"], "swatch": 5})), + ("addon.cutting.weed", json!({"spacing": "1 mm", "box": false, "direction": "vertical", "swatch": "no such swatch"})), + ("addon.cutting.weed", json!({"count": u64::MAX, "margin": "x", "gap": null, "weight": 0})), // Type Tools add-on. ("addon.type.textTweaker", json!({"attribute": "size", "from": 1296, "to": 0.1, "shape": "random", "seed": u64::MAX})), ("addon.type.textTweaker", json!({"attribute": "tracking", "from": f64::MAX, "to": -1e308, "shape": "spike"})), diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index ac98db8e9c..4542496fc5 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4330,6 +4330,17 @@ proptest! { survive("mutated .ai art styles", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; survive("mutated .ai art styles, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; } + + /// A `.ai` whose brushes, as the writer writes them, are damaged: the brush art with its frame, + /// the brush manager's order, the brushes' parameters, a brushed path's style and its object, cut + /// short or edited. + #[test] + fn ai_written_brushes_never_panic(cut in 0usize..BRUSHES_FUZZ_CHARS, edits in prop::collection::vec((0usize..BRUSHES_FUZZ_CHARS, prop::sample::select(vec!['0', '1', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U', 'n', 'X', 'W'])), 0..12)) { + let text = mutate_text(brushes_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("mutated .ai brushes", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + survive("mutated .ai brushes, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } } /// The range of [`ai_written_styles_never_panic`]'s cuts and edits: past the end of [`styles_text`]. @@ -4442,6 +4453,72 @@ fn the_symbol_library_fuzz_data_opens_with_its_symbols() { assert_eq!(names, ["S2", "S1"], "{warnings:?}"); } +/// The range of [`ai_written_brushes_never_panic`]'s cuts and edits: past the end of [`brushes_text`]. +const BRUSHES_FUZZ_CHARS: usize = 48_000; + +/// The editing data VectorCraft writes for a document whose brushes are a Calligraphic brush, a +/// Scatter brush in Tints and an Art brush in Tints (each art a wide block over a narrow one), with a +/// path drawn with the Calligraphic brush and one with the Art brush. +fn brushes_text() -> &'static str { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| { + use std::sync::Arc; + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Appearance, Node, NodeId}; + use vectorcraft_geom::{Rect, shapes}; + let block = |id: u64, r: Rect| { + Arc::new(Node::path(NodeId(id), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0))) + }; + let art = || Node::group(NodeId(1), vec![block(2, Rect::new(0.0, 0.0, 40.0, 4.0)), block(3, Rect::new(0.0, 4.0, 10.0, 8.0))]); + let nib = Calligraphic { + angle: 30.0, + roundness: 40.0, + size: 6.0, + variation: [20.0, 10.0, 2.0], + modes: Some([Variation::Rotation, Variation::Fixed, Variation::Pressure]), + }; + let lib = [ + Brush { name: "Nib".into(), kind: BrushKind::Calligraphic(nib) }, + Brush { name: "Spray".into(), kind: BrushKind::Scatter(Scatter { art: art(), colorization: Colorization::Tints, ..Scatter::default() }) }, + Brush { name: "Ribbon".into(), kind: BrushKind::Art(ArtBrush { art: art(), colorization: Colorization::Tints, ..ArtBrush::default() }) }, + ]; + let mut doc = Document::new(200.0, 100.0); + vectorcraft_brush::store(&mut doc, &lib); + let layer = doc.layers[0].id; + for (brush, y) in [("Nib", 30.0), ("Ribbon", 70.0)] { + let mut line = Node::path( + doc.alloc_id(), + shapes::line((10.0, y).into(), (190.0, y).into()), + Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0), + ); + line.appearance.stroke_mut().unwrap().brush = Some(brush.into()); + doc.insert(Some(layer), usize::MAX, line).unwrap(); + } + let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap(); + String::from_utf8(out.program).unwrap() + }) +} + +/// The data [`ai_written_brushes_never_panic`] damages, as it is, opens with its brushes and its +/// brushed paths live, and the property's cuts and edits reach all of it. +#[test] +fn the_written_brushes_fuzz_data_opens_live() { + let text = brushes_text(); + assert!(text.chars().count() < BRUSHES_FUZZ_CHARS, "{}", text.chars().count()); + for mark in ["%AI8_BeginBrushPattern", "%AI8_BeginPluginObject", "%AI9_BeginArtStyles", "/GObjRef :", "%AI5_BeginLayer", "\n1 (Anon"] { + assert!(text.replace("\r\n", "\n").contains(mark), "{mark}"); + } + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat(); + let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); + assert!(layered, "{warnings:?}"); + let names: Vec = vectorcraft_brush::library(&d).into_iter().map(|b| b.name).collect(); + assert_eq!(names, ["Nib", "Spray", "Ribbon"]); + let art = d.layers[0].children().unwrap(); + let brushes: Vec> = art.iter().map(|n| n.appearance.stroke().and_then(|s| s.brush.as_deref())).collect(); + assert_eq!(brushes, [Some("Nib"), Some("Ribbon")]); +} + /// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the /// symbol list and a layer of instances, as the format writes them. fn symbols_text() -> String { diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 3ca8a66b5f..6c35eb7a01 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -133,6 +133,16 @@ //! A fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that //! fills by the even-odd rule is written as its drawn look. A style is written only when the //! reader reads it back as the object's appearance (or the graphic style's). +//! - Brushes, as the reader reads them (`brush.rs`, `style.rs`): the Brushes panel's brushes as +//! plug-in objects in a non-printing block after the symbols (`%AI8_BeginPluginObject`, the +//! plug-in's name, the brush's name, its parameters in strings that `-` continues and `.` ends), +//! after the brush manager's order of them; the art of Scatter and Art brushes in a non-printing +//! block before the symbols (`%AI8_BeginBrushPattern`, its name, a group of an unpainted frame at +//! the art's bounds and the art, or the art alone when it has no width or height), when the +//! document has brushes of its own or an object is written with one. +//! A path or compound path with a brush the reader reads live is written with an art style whose +//! stroke is the brush's filter (`writer::brushes`). A brush is written only when the reader reads +//! it back with its settings. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the @@ -168,9 +178,10 @@ //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on //! layers and opacity masks nested too deep, and, on objects written without an art style, live //! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to -//! graphic styles on objects not written with them, and every art style on a large canvas, are left -//! out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads), -//! counted as the reader counts them, is left out. +//! graphic styles on objects not written with them, brushes the reader wouldn't read back, and every +//! art style and brush on a large canvas, are left out; art past +//! [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads), counted as the +//! reader counts them, is left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). @@ -288,6 +299,7 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re let mut w = Writer::new(doc, to_art, origin); // On a large canvas the layers' symbol instances are written as their art. w.symbols_live = scale == 1.0; + w.brushes_live = scale == 1.0; w.symbol_doc = symbol_doc; w.max_objects = max_objects; w.reserve_swatch_gradients(); @@ -305,6 +317,8 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re } // The symbols no instance uses too; their art adds definitions. w.symbols(); + // The brushes, once the layers have said whether an object is written with one. + let (brush_art, brush_objects) = w.brush_blocks(); // The palette first: its gradient and pattern swatches add definitions. let mut palette = String::new(); w.palette(&mut palette); @@ -323,7 +337,9 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re let mut s = header.clone(); s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n"); w.definitions(&mut s); + s.push_str(&brush_art); w.symbol_definitions(&mut s); + s.push_str(&brush_objects); w.art_styles(&mut s); s.push_str(&palette); w.art_style_list(&mut s); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 6baf4e802b..0ccfc23ca8 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -747,6 +747,7 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() { assert_eq!(layer[2].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0)))); } +/// A brushed stroke the reader reads as its drawn look (a Scatter brush's) is written as its brush's art. #[test] fn brushed_strokes_are_written_as_their_brush_art() { let mut doc = Document::new(200.0, 100.0); @@ -756,11 +757,11 @@ fn brushed_strokes_are_written_as_their_brush_art() { shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 4.0), ); - line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + line.appearance.stroke_mut().unwrap().brush = Some("Dots".into()); doc.insert(Some(layer), usize::MAX, line).unwrap(); let pieces = vectorcraft_brush::stroke_pieces( &doc, - &vectorcraft_brush::find(&doc, "3 pt. Round").unwrap(), + &vectorcraft_brush::find(&doc, "Dots").unwrap(), &shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()).to_bezpath(), doc.layers[0].children().unwrap()[0].appearance.stroke().unwrap(), ); @@ -895,6 +896,7 @@ fn counted() -> Vec<(&'static str, Document)> { ("styles", styles_document()), ("painted clips", painted_clips()), ("blend", blend_doc()), + ("brushes", brushes_document()), ] } @@ -6911,6 +6913,28 @@ fn a_link_to_a_style_that_moves_the_outline_is_kept_where_the_reader_reads_it() assert!(n.extras().mask.is_some(), "{n:?}"); } +/// An object linked to a graphic style that doesn't fit within the object limit is left out with +/// the limit's warning and the warning about links left out. +#[test] +fn a_linked_object_past_the_object_limit_warns_that_its_link_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }; + let mut g = vectorcraft_doc::GraphicStyle::new("Shadowed", ap.clone()); + g.id = 9; + doc.graphic_styles.push(g); + // Room for everything but the object. + let room = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(9); + put(&mut doc, n); + let all = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT || w == writer::STYLE_LINKS), "{:?}", all.warnings); + let out = export_within(&doc, &opts(), room).unwrap(); + for w in [writer::OBJECT_LIMIT, writer::STYLE_LINKS] { + assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); + } +} + /// Twist and Transform on an object that isn't linked to a graphic style are not written as a style /// of its own: the object reads back without live effects. #[test] @@ -7258,6 +7282,772 @@ fn a_large_canvas_writes_every_object_as_its_look() { assert!((p.x - 10.0).abs() < 1e-2 && (p.y - 10.0).abs() < 1e-2, "{p:?}"); } +// ---------- brushes ---------- + +/// Brush art: a wide block with a narrow one below it (y down), filled black. +fn brush_art_node() -> Node { + let block = + |id: u64, r: Rect| Arc::new(Node::path(vectorcraft_doc::NodeId(id), shapes::rectangle(r), Appearance::basic(black_fill(), Paint::None, 0.0))); + Node::group(vectorcraft_doc::NodeId(1), vec![block(2, Rect::new(0.0, 0.0, 40.0, 4.0)), block(3, Rect::new(0.0, 4.0, 10.0, 8.0))]) +} + +/// A Calligraphic brush varying with the barrel's rotation and the pen's pressure, a Scatter brush in +/// Tints, an Art brush in Tints and one that keeps its art's colors. +fn brush_library() -> Vec { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; + let art = |colorization| BrushKind::Art(ArtBrush { art: brush_art_node(), colorization, ..ArtBrush::default() }); + vec![ + Brush { + name: "Nib".into(), + kind: BrushKind::Calligraphic(Calligraphic { + angle: 30.0, + roundness: 40.0, + size: 6.0, + variation: [20.0, 10.0, 2.0], + modes: Some([Variation::Rotation, Variation::Fixed, Variation::Pressure]), + }), + }, + Brush { + name: "Spray".into(), + kind: BrushKind::Scatter(Scatter { + art: brush_art_node(), + size: (50.0, 150.0), + spacing: (120.0, 200.0), + scatter: (-25.0, 25.0), + rotation: (-45.0, 30.0), + rotation_relative_to_path: true, + colorization: Colorization::Tints, + modes: Some([Variation::Random, Variation::Fixed, Variation::Pressure, Variation::Random]), + }), + }, + Brush { name: "Ribbon".into(), kind: art(Colorization::Tints) }, + Brush { name: "Plain ribbon".into(), kind: art(Colorization::None) }, + ] +} + +/// A path stroked red at `width` with the brush `brush`, on the first layer of `doc`. +fn brushed(doc: &mut Document, brush: &str, from: (f64, f64), to: (f64, f64), width: f64) { + let mut line = + Node::path(doc.alloc_id(), shapes::line(from.into(), to.into()), Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), width)); + line.appearance.stroke_mut().unwrap().brush = Some(brush.into()); + put(doc, line); +} + +/// [`brush_library`] and a path with the Calligraphic brush and one with the Art brush in Tints. +fn brushes_document() -> Document { + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + brushed(&mut doc, "Nib", (20.0, 50.0), (280.0, 60.0), 2.0); + brushed(&mut doc, "Ribbon", (20.0, 120.0), (280.0, 150.0), 1.5); + doc +} + +/// Brush art `n` as a tree to compare with [`same_tree`]: each object's kind, its anchors and handles, +/// the fills and strokes that paint (a stroke with its width), and its members in order. +fn art_tree(n: &Node) -> serde_json::Value { + let kind = match &n.kind { + NodeKind::Path { .. } => "path", + NodeKind::Compound { .. } => "compound", + NodeKind::Group { clip: false, .. } => "group", + _ => "other", + }; + let anchors: Vec<[f64; 6]> = match &n.kind { + NodeKind::Path { path, .. } => { + path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| [a.h_in.x, a.h_in.y, a.p.x, a.p.y, a.h_out.x, a.h_out.y])).collect() + } + _ => vec![], + }; + let paints: Vec = n + .appearance + .items + .iter() + .filter(|i| !i.paint().is_none()) + .map(|i| match i { + AppearanceItem::Fill(f) => serde_json::json!(["fill", f.paint]), + AppearanceItem::Stroke(s) => serde_json::json!(["stroke", s.paint, s.width]), + }) + .collect(); + let members: Vec = n.children().map(|c| c.iter().map(|m| art_tree(m)).collect()).unwrap_or_default(); + serde_json::json!({"kind": kind, "anchors": anchors, "paints": paints, "members": members}) +} + +/// Are trees `a` and `b` ([`art_tree`]) the same but for numbers within the written numbers' +/// precision (1/1000)? +fn same_tree(a: &serde_json::Value, b: &serde_json::Value) -> bool { + use serde_json::Value; + match (a, b) { + (Value::Number(x), Value::Number(y)) => (x.as_f64().unwrap() - y.as_f64().unwrap()).abs() <= 1e-3, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| same_tree(p, q)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_tree(v, w))), + _ => a == b, + } +} + +/// The document's brushes come back in the Brushes panel's order with their settings and art: the +/// Calligraphic brush's channels, the Scatter brush's ranges, modes and Tints, the Art brushes' +/// colorizations. +#[test] +fn brushes_are_read_back_with_their_settings() { + use vectorcraft_brush::BrushKind; + let doc = brushes_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let text = program(&doc); + assert_eq!(text.matches("%AI8_BeginPluginObject").count(), 5, "the manager's order and four brushes"); + assert_eq!(text.matches("%AI8_BeginBrushPattern").count(), 3); + let back = vectorcraft_brush::library(&read_back(&doc)); + let lib = brush_library(); + assert_eq!(back.iter().map(|b| b.name.as_str()).collect::>(), ["Nib", "Spray", "Ribbon", "Plain ribbon"]); + assert_eq!(back[0], lib[0]); + let (BrushKind::Scatter(got), BrushKind::Scatter(want)) = (&back[1].kind, &lib[1].kind) else { panic!("scatter") }; + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3; + assert!( + near(got.size, want.size) && near(got.spacing, want.spacing) && near(got.scatter, want.scatter) && near(got.rotation, want.rotation), + "{got:?}" + ); + assert_eq!((got.modes, got.colorization, got.rotation_relative_to_path), (want.modes, want.colorization, true)); + let (g, w) = (art_tree(&got.art), art_tree(&want.art)); + assert!(same_tree(&g, &w), "the art without its frame, with its paint and members in order: {g:#} for {w:#}"); + for (i, colorization) in [(2, vectorcraft_brush::Colorization::Tints), (3, vectorcraft_brush::Colorization::None)] { + let BrushKind::Art(a) = &back[i].kind else { panic!("art") }; + assert_eq!(a.colorization, colorization); + let (g, w) = (art_tree(&a.art), art_tree(&brush_art_node())); + assert!(same_tree(&g, &w), "the narrow block stays below the wide one, and no frame stays: {g:#} for {w:#}"); + let BrushKind::Art(want) = &lib[i].kind else { panic!("art") }; + assert_eq!(ArtBrushOptions::of(a), ArtBrushOptions::of(want)); + } +} + +/// An Art brush's options, without its art. +#[derive(Debug, PartialEq)] +struct ArtBrushOptions(serde_json::Value); + +impl ArtBrushOptions { + fn of(a: &vectorcraft_brush::ArtBrush) -> Self { + let mut v = serde_json::to_value(a).unwrap(); + v.as_object_mut().unwrap().remove("art"); + Self(v) + } +} + +/// A path with the Calligraphic brush, and one with an Art brush in Tints whose art bends along the +/// path, come back as those paths with the brush on their stroke at its weight, not as their drawn +/// look; a path with the Art brush that keeps its art's colors comes back as its drawn look. +#[test] +fn brushed_paths_are_read_back_brushed() { + let mut doc = brushes_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 2); + for (n, (brush, width)) in art.iter().zip([("Nib", 2.0), ("Ribbon", 1.5)]) { + assert!(matches!(n.kind, NodeKind::Path { .. }), "{brush}: the path, not its look: {n:#?}"); + let st = n.appearance.stroke().unwrap(); + assert_eq!((st.brush.as_deref(), st.width, st.paint.color()), (Some(brush), width, Some(Color::rgb(1.0, 0.0, 0.0)))); + assert!(n.appearance.fill().is_none_or(|f| f.paint.is_none())); + } + let ours = doc.layers[0].children().unwrap(); + assert_eq!(art[0].path_data(), ours[0].path_data()); + assert_eq!(art[1].path_data(), ours[1].path_data()); + brushed(&mut doc, "Plain ribbon", (20.0, 180.0), (280.0, 180.0), 1.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + assert!(matches!(read_back(&doc).layers[0].children().unwrap()[2].kind, NodeKind::Group { .. })); +} + +/// A compound path with the Calligraphic brush comes back as that compound path with the brush on its +/// stroke; a filled ellipse with it comes back with its fill and the brush. +#[test] +fn brushed_compound_and_filled_paths_are_read_back_brushed() { + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 2.0); + stroke.brush = Some("Nib".into()); + let ring = ring(&mut doc, Appearance { items: vec![AppearanceItem::Stroke(stroke.clone())], effects: vec![], contents_index: None }); + put(&mut doc, ring); + let fill = vectorcraft_doc::FillLayer::new(solid(Color::rgb(0.2, 0.6, 0.3))); + let ap = Appearance { items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)], effects: vec![], contents_index: None }; + let ellipse = Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(20.0, 120.0, 140.0, 180.0)), ap); + put(&mut doc, ellipse); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES || w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + let (art, ours) = (back.layers[0].children().unwrap(), doc.layers[0].children().unwrap()); + assert!(matches!(art[0].kind, NodeKind::Compound { .. }), "the compound path, not its look: {:#?}", art[0]); + assert!(same_tree(&art_tree(&art[0])["members"], &art_tree(&ours[0])["members"]), "{:#?}", art[0]); + assert!(matches!(art[1].kind, NodeKind::Path { .. }), "the ellipse, not its look: {:#?}", art[1]); + assert!(same_tree(&art_tree(&art[1])["anchors"], &art_tree(&ours[1])["anchors"]), "{:#?}", art[1]); + assert_eq!(art[1].appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(0.2, 0.6, 0.3))); + for n in art { + let st = n.appearance.stroke().unwrap(); + assert_eq!((st.brush.as_deref(), st.width), (Some("Nib"), 2.0)); + } +} + +/// A path with an Art brush in Tints whose art doesn't bend along the path (its art has a painted +/// stroke) comes back as its drawn look, with the stroke warning; the brush itself comes back. +#[test] +fn an_art_brush_whose_art_doesnt_bend_keeps_its_paths_look() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Colorization}; + let mut art = brush_art_node(); + if let Some(c) = art.children_mut() { + for m in c.iter_mut() { + Arc::make_mut(m).appearance = Appearance::basic(black_fill(), black_fill(), 1.0).into(); + } + } + let brush = + Brush { name: "Stroked ribbon".into(), kind: BrushKind::Art(ArtBrush { art, colorization: Colorization::Tints, ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, std::slice::from_ref(&brush)); + brushed(&mut doc, "Stroked ribbon", (20.0, 120.0), (280.0, 150.0), 1.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Group { .. })); + let lib = vectorcraft_brush::library(&back); + let (BrushKind::Art(got), BrushKind::Art(want)) = (&lib[0].kind, &brush.kind) else { panic!("art") }; + assert!(same_tree(&art_tree(&got.art), &art_tree(&want.art)), "{:#?}", got.art); +} + +/// Settings the other tests don't use come back: a Calligraphic brush varying with tilt and bearing, +/// a Scatter brush keeping its art's colors whose rotation doesn't follow the path, and brushes whose +/// modes aren't given (the reader gives them, the same ones). +#[test] +fn more_brush_settings_are_read_back() { + use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; + let tilted = Calligraphic { + angle: -40.0, + roundness: 75.0, + size: 9.0, + variation: [10.0, 5.0, 3.0], + modes: Some([Variation::Tilt, Variation::Bearing, Variation::Random]), + }; + let scatter = Scatter { + art: brush_art_node(), + size: (80.0, 80.0), + spacing: (100.0, 100.0), + scatter: (0.0, 0.0), + rotation: (15.0, 15.0), + rotation_relative_to_path: false, + colorization: Colorization::None, + modes: Some([Variation::Tilt, Variation::Bearing, Variation::Fixed, Variation::Rotation]), + }; + let lib = vec![ + Brush { name: "Tilted".into(), kind: BrushKind::Calligraphic(tilted.clone()) }, + Brush { name: "Still".into(), kind: BrushKind::Scatter(scatter.clone()) }, + Brush { name: "Unset nib".into(), kind: BrushKind::Calligraphic(Calligraphic { modes: None, ..tilted }) }, + Brush { name: "Unset spray".into(), kind: BrushKind::Scatter(Scatter { modes: None, ..scatter }) }, + ]; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), lib.len()); + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3; + for (got, want) in back.iter().zip(&lib) { + assert_eq!(got.name, want.name); + match (&got.kind, &want.kind) { + (BrushKind::Calligraphic(g), BrushKind::Calligraphic(w)) => { + assert_eq!(g.modes(), w.modes(), "{}", want.name); + assert_eq!(Calligraphic { modes: None, ..g.clone() }, Calligraphic { modes: None, ..w.clone() }, "{}", want.name); + } + (BrushKind::Scatter(g), BrushKind::Scatter(w)) => { + assert_eq!(g.modes(), w.modes(), "{}", want.name); + assert!(near(g.size, w.size) && near(g.spacing, w.spacing) && near(g.scatter, w.scatter) && near(g.rotation, w.rotation), "{g:?}"); + assert_eq!((g.colorization, g.rotation_relative_to_path), (Colorization::None, false), "{}", want.name); + assert!(same_tree(&art_tree(&g.art), &art_tree(&w.art)), "{}: {:#?}", want.name, g.art); + } + _ => panic!("{}: {got:?}", want.name), + } + } +} + +/// Brush art without width or height, a straight stroked line, comes back as that line, without a +/// frame or a group around it: a Scatter brush's horizontal line and an Art brush's vertical one. +#[test] +fn brush_art_of_a_straight_line_comes_back_as_the_line() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Scatter}; + let line = |id: u64, to: (f64, f64)| { + Node::path(vectorcraft_doc::NodeId(id), shapes::line((0.0, 0.0).into(), to.into()), Appearance::basic(Paint::None, black_fill(), 2.0)) + }; + let lib = vec![ + Brush { name: "Dash".into(), kind: BrushKind::Scatter(Scatter { art: line(1, (40.0, 0.0)), ..Scatter::default() }) }, + Brush { name: "Post".into(), kind: BrushKind::Art(ArtBrush { art: line(2, (0.0, 40.0)), ..ArtBrush::default() }) }, + ]; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), 2); + for (got, want) in back.iter().zip(&lib) { + let art = |b: &Brush| match &b.kind { + BrushKind::Scatter(s) => s.art.clone(), + BrushKind::Art(a) => a.art.clone(), + _ => panic!("{}", b.name), + }; + let (g, w) = (art_tree(&art(got)), art_tree(&art(want))); + assert!(same_tree(&g, &w), "{}: {g:#} for {w:#}", want.name); + } +} + +/// Brush art of a path with several subpaths is written as a compound path, which the reader reads as +/// a compound path of one path for each subpath, and is weighed as the reader weighs that against the +/// art it copies into one file's brushes: of two Art brushes whose art is a path of 30,000 squares, +/// the second is left out with a warning, so that what is written is what comes back. +#[test] +fn brush_art_of_a_path_with_subpaths_is_weighed_as_it_is_read() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let squares = |n: u32| { + let mut path = PathData::default(); + for i in 0..n { + let x = f64::from(i % 200) * 2.0; + let y = f64::from(i / 200) * 2.0; + path.subpaths.extend(shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)).subpaths); + } + Node::path(vectorcraft_doc::NodeId(1), path, Appearance::basic(black_fill(), Paint::None, 0.0)) + }; + let brush = |name: &str, n: u32| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art: squares(n), ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("Two", 2)]); + let back = vectorcraft_brush::library(&read_back(&doc)); + let BrushKind::Art(a) = &back[0].kind else { panic!("art") }; + let tree = art_tree(&a.art); + assert_eq!(tree["kind"], "compound", "{tree:#}"); + assert_eq!(tree["members"].as_array().map(Vec::len), Some(2), "{tree:#}"); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("Many 1", 30_000), brush("Many 2", 30_000)]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let names: Vec = vectorcraft_brush::library(&read_back(&doc)).into_iter().map(|b| b.name).collect(); + assert_eq!(names, ["Many 1"]); + assert_eq!(program(&doc).matches("%AI8_BeginBrushPattern").count(), 1); +} + +/// Brush art that is a compound path is written as one path of all its members' subpaths, and is +/// weighed as the reader reads that back: a compound path of 24,000 members of two squares each is +/// read as 48,000 paths, within what the reader copies into one file's brushes, so the brush is +/// written without a warning; a compound path with one square comes back as one path. +#[test] +fn brush_art_of_a_compound_path_is_weighed_as_it_is_read() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let square = |i: u32| { + let x = f64::from(i % 400) * 2.0; + let y = f64::from(i / 400) * 2.0; + shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)).subpaths + }; + let compound = |members: u32, per: u32| { + let children = (0..members) + .map(|m| { + let path = PathData::new((0..per).flat_map(|k| square(m * per + k)).collect()); + Arc::new(Node::path(vectorcraft_doc::NodeId(2), path, Appearance::basic(black_fill(), Paint::None, 0.0))) + }) + .collect(); + let mut n = Node::path(vectorcraft_doc::NodeId(1), PathData::default(), Appearance::basic(black_fill(), Paint::None, 0.0)); + n.kind = NodeKind::Compound { children, rule: FillRule::NonZero }; + n + }; + let brush = |name: &str, art: Node| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art, ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("One", compound(1, 1))]); + let back = vectorcraft_brush::library(&read_back(&doc)); + let BrushKind::Art(a) = &back[0].kind else { panic!("art") }; + assert_eq!(art_tree(&a.art)["kind"], "path", "{:#}", art_tree(&a.art)); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("Many", compound(24_000, 2))]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), 1); + let BrushKind::Art(a) = &back[0].kind else { panic!("art") }; + assert_eq!(a.art.children().map(Vec::len), Some(48_000)); +} + +/// A path with a drop shadow and the Calligraphic brush keeps the brush and the effect, and its drawn +/// look doesn't warn about the brushed stroke; a group with a drop shadow whose member has the Scatter +/// brush still warns, as the member is written as its brush's art. +#[test] +fn a_brushed_path_with_effects_keeps_its_brush_without_the_stroke_warning() { + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + let mut line = Node::path( + doc.alloc_id(), + shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()), + Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 2.0), + ); + line.appearance.stroke_mut().unwrap().brush = Some("Nib".into()); + line.appearance.effects = vec![drop_shadow()]; + put(&mut doc, line); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Path { .. }), "the path, not its look: {n:#?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Nib")); + assert_eq!(n.appearance.effects.len(), 1); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + let mut member = Node::path( + doc.alloc_id(), + shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()), + Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 2.0), + ); + member.appearance.stroke_mut().unwrap().brush = Some("Spray".into()); + let mut g = group(&mut doc, vec![member], false); + g.appearance.effects = vec![drop_shadow()]; + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); +} + +/// A brushed stroke that paints nothing isn't kept by an art style: a path with only such a stroke is +/// written without a style, and a document whose brushes are a new document's writes no brushes. +#[test] +fn a_brushed_stroke_that_paints_nothing_writes_no_brushes() { + let mut doc = Document::new(300.0, 200.0); + let mut line = + Node::path(doc.alloc_id(), shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()), Appearance::basic(Paint::None, Paint::None, 1.0)); + line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + put(&mut doc, line); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let text = program(&doc); + assert!(!text.contains("%AI8_BeginPluginObject"), "no brushes"); + assert!(!text.contains("/KnownStyle"), "no art style"); +} + +/// The lines of `program` that hold brushes and what is drawn with them: the brush art, the plug-in +/// objects, the art styles and the layers. +fn brush_lines(program: &str) -> Vec<&str> { + let mut out = vec![]; + let mut inside = false; + for line in program.lines() { + if ["%AI8_BeginBrushPattern", "%AI8_BeginPluginObject", "%AI9_BeginArtStyles", "%%EndSetup"].iter().any(|m| line.starts_with(m)) { + inside = true; + } + if inside { + out.push(line); + } + if ["%AI8_EndBrushPattern", "%AI8_EndPluginObject", "%AI9_EndArtStyles"].iter().any(|m| line.starts_with(m)) { + inside = false; + } + } + out +} + +/// A document with brushes and brushed paths is written as the same bytes again, and what the reader +/// reads back is written with the same brushes, art styles and layers. +#[test] +fn brushes_write_the_same_bytes_again() { + let doc = brushes_document(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap(); + let first = String::from_utf8(first.program).unwrap(); + let (again, first) = (brush_lines(&again), brush_lines(&first)); + let differ = again.iter().zip(&first).enumerate().find(|(_, (a, b))| a != b); + assert_eq!(differ, None, "the first line that differs"); + assert_eq!(again.len(), first.len()); +} + +/// Brushes the reader wouldn't read back as they are are left out with a warning, each on its own +/// beside a brush that comes back, and each changed from a brush that comes back by the one setting +/// that leaves it out: an Art brush whose art is a group with a fill of its own, a Scatter brush whose +/// art is a path without points, an Art brush whose art is a group without members, Pattern and Bristle +/// brushes, Art brushes flipped along or across, overlapping, at another width, with a width that +/// varies, in another direction or scale, Art and Scatter brushes in Tints and Shades or Hue Shift, a +/// brush whose name holds a slash and a second brush of a name. A brush left out doesn't take its +/// name from a later one. +#[test] +fn brushes_the_reader_cant_read_are_left_out() { + use vectorcraft_brush::{ArtBrush, ArtScale, Bristle, Brush, BrushKind, Colorization, Direction, PatternBrush, Variation}; + let lib = brush_library(); + let keep = lib[0].clone(); + let (BrushKind::Scatter(scatter), BrushKind::Art(art)) = (&lib[1].kind, &lib[2].kind) else { panic!("library") }; + let hue = Colorization::HueShift { key: Color::rgb(1.0, 0.0, 0.0) }; + let art_with = |f: &dyn Fn(&mut ArtBrush)| { + let mut a = art.clone(); + f(&mut a); + BrushKind::Art(a) + }; + let mut painted_group = brush_art_node(); + painted_group.appearance = Appearance::basic(black_fill(), Paint::None, 0.0).into(); + let empty_path = Node::path(vectorcraft_doc::NodeId(1), PathData::new(vec![]), Appearance::basic(black_fill(), Paint::None, 0.0)); + let cases: Vec<(&str, BrushKind)> = vec![ + ("painted group", art_with(&|a| a.art = painted_group.clone())), + ("empty path", BrushKind::Scatter(vectorcraft_brush::Scatter { art: empty_path.clone(), ..scatter.clone() })), + ("empty group", art_with(&|a| a.art = Node::group(vectorcraft_doc::NodeId(1), vec![]))), + ("pattern", BrushKind::Pattern(PatternBrush::default())), + ("bristle", BrushKind::Bristle(Bristle::default())), + ("flipped along", art_with(&|a| a.flip_along = true)), + ("flipped across", art_with(&|a| a.flip_across = true)), + ("overlap", art_with(&|a| a.overlap = true)), + ("width", art_with(&|a| a.width = 50.0)), + ("width varies", art_with(&|a| a.width_mode = Some(Variation::Pressure))), + ("direction", art_with(&|a| a.direction = Direction::RightToLeft)), + ("proportional", art_with(&|a| a.scale = ArtScale::Proportional)), + ("art tints and shades", art_with(&|a| a.colorization = Colorization::TintsAndShades)), + ("art hue shift", art_with(&|a| a.colorization = hue)), + ( + "scatter tints and shades", + BrushKind::Scatter(vectorcraft_brush::Scatter { colorization: Colorization::TintsAndShades, ..scatter.clone() }), + ), + ("scatter hue shift", BrushKind::Scatter(vectorcraft_brush::Scatter { colorization: hue, ..scatter.clone() })), + ]; + let left_out = |extra: Vec, why: &str| { + let mut doc = Document::new(300.0, 200.0); + let mut lib = vec![keep.clone()]; + lib.extend(extra); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{why}: {:?}", out.warnings); + assert_eq!(vectorcraft_brush::library(&read_back(&doc)), std::slice::from_ref(&keep), "{why}"); + }; + for (why, kind) in cases { + left_out(vec![Brush { name: "Other".into(), kind }], why); + } + left_out(vec![Brush { name: "A/B".into(), ..keep.clone() }], "slash"); + left_out(vec![Brush { kind: BrushKind::Bristle(Bristle::default()), ..keep.clone() }], "a second brush of the name"); + // A Pattern brush named "X" is left out, so a Calligraphic brush named "X" after it comes back. A + // path with "X" is drawn with the Pattern brush, so it comes back as its drawn look. + let mut doc = Document::new(300.0, 200.0); + let x = Brush { name: "X".into(), ..keep.clone() }; + vectorcraft_brush::store(&mut doc, &[Brush { name: "X".into(), kind: BrushKind::Pattern(PatternBrush::default()) }, x.clone()]); + brushed(&mut doc, "X", (20.0, 50.0), (280.0, 60.0), 2.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(vectorcraft_brush::library(&back), [x]); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.stroke().is_none_or(|s| s.brush.is_none()), "not with the other brush of the name: {n:#?}"); +} + +/// Brushes past the most the reader reads from one file are left out with a warning: of 4,097 +/// Calligraphic brushes, the first 4,096 come back. +#[test] +fn brushes_past_the_readers_count_are_left_out() { + let nib = brush_library().remove(0); + let lib: Vec = (0..4097).map(|i| vectorcraft_brush::Brush { name: format!("Nib {i}"), ..nib.clone() }).collect(); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), 4096); + assert_eq!(back.last().map(|b| b.name.as_str()), Some("Nib 4095")); +} + +/// A brush whose art the editing data would change (a stroke inside the path, written as its +/// outline) is left out, also after an object on the layers was already written with that change. +#[test] +fn a_brush_whose_art_would_change_is_left_out() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let inside = |doc: &mut Document| { + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 8.0)), Appearance::basic(Paint::None, black_fill(), 2.0)); + n.appearance.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Inside; + n + }; + let mut doc = Document::new(300.0, 200.0); + let on_layer = inside(&mut doc); + put(&mut doc, on_layer); + let keep = brush_library().remove(0); + let art = inside(&mut doc); + vectorcraft_brush::store( + &mut doc, + &[keep.clone(), Brush { name: "Outlined".into(), kind: BrushKind::Art(ArtBrush { art, ..ArtBrush::default() }) }], + ); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert_eq!(vectorcraft_brush::library(&read_back(&doc)), [keep]); +} + +/// Brushes past the art the reader copies into one file's brushes are left out with a warning, so +/// that what is written is what comes back: three Art brushes of 100,000 anchors each, of which the +/// reader keeps two. +#[test] +fn brushes_past_the_readers_art_limit_are_left_out() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let heavy = |id: u64| { + let points: Vec = + (0..100_000).map(|i| vectorcraft_geom::Point::new(f64::from(i) / 1000.0, if i % 2 == 0 { 0.0 } else { 4.0 })).collect(); + let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + Node::path(vectorcraft_doc::NodeId(id), path, Appearance::basic(black_fill(), Paint::None, 0.0)) + }; + let lib: Vec = + (1..=3).map(|i| Brush { name: format!("Heavy {i}"), kind: BrushKind::Art(ArtBrush { art: heavy(i), ..ArtBrush::default() }) }).collect(); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back: Vec = vectorcraft_brush::library(&read_back(&doc)).into_iter().map(|b| b.name).collect(); + assert_eq!(back, ["Heavy 1", "Heavy 2"]); + assert_eq!(program(&doc).matches("%AI8_BeginBrushPattern").count(), 2); + // The art of the brush left out isn't counted: there is room for it all with room for what is read. + let read = crate::import::objects_read(&out.program).unwrap(); + let room = export_within(&doc, &opts(), read).unwrap(); + assert!(!room.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{read}: {:?}", room.warnings); +} + +/// A brush whose art doesn't fit within the object limit is left out with the limit's warning, and +/// what its art took is given back: a later brush whose art fits comes back. +#[test] +fn a_brush_whose_art_passes_the_object_limit_leaves_room_for_the_next() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let squares = |n: u64| { + let block = |i: u64| { + let x = i as f64 * 10.0; + Arc::new(Node::path( + vectorcraft_doc::NodeId(i + 1), + shapes::rectangle(Rect::new(x, 0.0, x + 5.0, 5.0)), + Appearance::basic(black_fill(), Paint::None, 0.0), + )) + }; + Node::group(vectorcraft_doc::NodeId(100), (0..n).map(block).collect()) + }; + let art = |name: &str, n: u64| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art: squares(n), ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[art("Big", 6), art("Small", 2)]); + let all = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", all.warnings); + let mut small = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut small, &[art("Small", 2)]); + let room = crate::import::objects_read(&export_ai_editing(&small, &opts()).unwrap().program).unwrap(); + let out = export_within(&doc, &opts(), room).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + assert!(crate::import::objects_read(&out.program).unwrap() <= room); + let mut data = ZSTD_MARKER.to_vec(); + data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest)); + let back = crate::ai_alone(&data).unwrap().0; + let names: Vec = vectorcraft_brush::library(&back).into_iter().map(|b| b.name).collect(); + assert_eq!(names, ["Small"]); +} + +/// A graphic style with a brushed stroke, which is left out, takes no room from the layers' objects: +/// a document with a new document's brushes and no brushed path writes no brush art, so with room for +/// what its layers write, every object comes back. +#[test] +fn a_graphic_style_with_a_brush_takes_no_room_from_the_layers() { + let mut doc = Document::new(300.0, 200.0); + for i in 0..3 { + let x = 20.0 + f64::from(i) * 40.0; + let square = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(x, 20.0, x + 30.0, 50.0)), Appearance::basic(black_fill(), Paint::None, 0.0)); + put(&mut doc, square); + } + let room = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 1.0); + stroke.brush = Some("3 pt. Round".into()); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![], contents_index: None }; + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Brushed", ap)); + let out = export_within(&doc, &opts(), room).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{room}: {:?}", out.warnings); + let program = String::from_utf8(out.program).unwrap(); + assert!(!program.contains("%AI8_BeginBrushPattern"), "no brush art"); +} + +/// A graphic style whose brushed stroke paints nothing comes back in the Graphic Styles panel. +#[test] +fn a_graphic_style_with_an_unpainted_brushed_stroke_comes_back() { + let mut doc = Document::new(300.0, 200.0); + let mut ap = Appearance::basic(black_fill(), Paint::None, 1.0); + ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Unpainted brush", ap)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + assert!(read_back(&doc).graphic_style("Unpainted brush").is_some()); +} + +/// A brush whose art doesn't fit within the object limit after a path was written with it on its +/// stroke is left out with both warnings, and the path comes back as its drawn look. +#[test] +fn a_brushed_path_whose_brush_art_doesnt_fit_comes_back_as_its_look() { + let ribbon = brush_library().remove(2); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, std::slice::from_ref(&ribbon)); + brushed(&mut doc, "Ribbon", (20.0, 120.0), (280.0, 150.0), 1.5); + let all = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT || w == writer::BRUSHES), "{:?}", all.warnings); + let room = crate::import::objects_read(&all.program).unwrap() - 1; + let out = export_within(&doc, &opts(), room).unwrap(); + for w in [writer::OBJECT_LIMIT, writer::BRUSHES] { + assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); + } + assert!(crate::import::objects_read(&out.program).unwrap() <= room); + let mut data = ZSTD_MARKER.to_vec(); + data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest)); + let back = crate::ai_alone(&data).unwrap().0; + assert!(vectorcraft_brush::library(&back).is_empty()); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Group { .. }), "the path's drawn look: {n:#?}"); +} + +/// An empty Brushes panel comes back empty, and a document with a new document's brushes and no +/// brushed path writes no brushes (the reader gives such a file a new document's). +#[test] +fn an_empty_brushes_panel_comes_back_empty() { + let mut doc = Document::new(300.0, 200.0); + assert!(!program(&doc).contains("%AI8_BeginPluginObject")); + assert_eq!(vectorcraft_brush::library(&read_back(&doc)), vectorcraft_brush::library(&doc)); + vectorcraft_brush::store(&mut doc, &[]); + assert!(vectorcraft_brush::library(&read_back(&doc)).is_empty()); +} + +/// A path with one of a new document's brushes writes the brushes the reader reads, and keeps its +/// brush. The new document's Art brush with other options, its Pattern brushes and its Bristle +/// brushes are left out with a warning, so the file opens without them. +#[test] +fn a_new_documents_brush_on_a_path_writes_the_brushes() { + let mut doc = Document::new(300.0, 200.0); + brushed(&mut doc, "3 pt. Round", (20.0, 50.0), (280.0, 60.0), 1.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("3 pt. Round")); + let left_out = ["Arrow", "Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"]; + let want: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).filter(|n| !left_out.contains(n)).collect(); + let lib = vectorcraft_brush::library(&back); + assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::>(), want); + assert_eq!(vectorcraft_brush::defaults().len(), want.len() + left_out.len()); +} + +/// A path with one of a new document's brushes that doesn't fit within the object limit is left +/// out, and no brushes are written: the file opens with a new document's brushes, all of them. +#[test] +fn a_new_documents_brush_on_a_path_past_the_object_limit_writes_no_brushes() { + let empty = Document::new(300.0, 200.0); + let room = crate::import::objects_read(&export_ai_editing(&empty, &opts()).unwrap().program).unwrap(); + let mut doc = Document::new(300.0, 200.0); + brushed(&mut doc, "3 pt. Round", (20.0, 50.0), (280.0, 60.0), 1.0); + let out = export_within(&doc, &opts(), room).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert!(!String::from_utf8(out.program).unwrap().contains("%AI8_BeginPluginObject")); +} + +/// On a large canvas the brushes are left out with a warning, and brushed paths come back as their +/// drawn look. +#[test] +fn a_large_canvas_leaves_the_brushes_out() { + let mut doc = Document::new(20000.0, 100.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + brushed(&mut doc, "Nib", (20.0, 50.0), (280.0, 60.0), 2.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert!(!program(&doc).contains("%AI8_BeginPluginObject")); + assert!(matches!(read_back(&doc).layers[0].children().unwrap()[0].kind, NodeKind::Group { .. })); + // An empty panel is kept, without a warning. + let mut doc = Document::new(20000.0, 100.0); + vectorcraft_brush::store(&mut doc, &[]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert!(vectorcraft_brush::library(&read_back(&doc)).is_empty()); +} + // ---------- variables ---------- /// A 200 × 100 document with variables: a text variable `name` bound to point type, a visibility diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 35a47cc437..873c607b90 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -15,6 +15,7 @@ use vectorcraft_pathops::BoolOp; use crate::ai_ops::{self, unit}; use crate::ps; +mod brushes; mod images; mod paints; mod styles; @@ -22,6 +23,8 @@ mod symbols; mod texts; mod variables; +#[cfg(test)] +pub(super) use brushes::BRUSHES; pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; #[cfg(test)] pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance}; @@ -51,7 +54,7 @@ pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths tha pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps"; pub(super) const LIVE: &str = "blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; -const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; +pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; pub(super) const ITEM_TRANSPARENCY: &str = "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; pub(super) const LAYER_MASKS: &str = "opacity masks on layers are left out of the editing data for other apps"; @@ -255,6 +258,10 @@ pub(super) struct Writer<'a> { /// An object's drawn look is being written: no object in it is written with a style, and its /// effects are the style's. in_look: bool, + /// The brushes written ([`brushes`]). + brush_set: brushes::Brushes, + /// Brushes may be written; false on a large canvas, where they are left out. + pub(super) brushes_live: bool, } impl<'a> Writer<'a> { @@ -310,6 +317,8 @@ impl<'a> Writer<'a> { styled: HashMap::new(), styles: styles::Styles::default(), in_look: false, + brush_set: brushes::Brushes::default(), + brushes_live: true, objects: 0, left_out: 0, definition_objects: 0, @@ -1028,7 +1037,12 @@ impl<'a> Writer<'a> { AppearanceItem::Fill(f) => self.fill_object(path, rule, f, xy), AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy), AppearanceItem::Stroke(s) => { - self.warn(STROKES); + // In a drawn look a brushed stroke is kept by the object's art style, which is written + // only when the reader reads the brush back from it; a styled group's members are + // written again after the look, and warn there. + if !(self.in_look && s.brush.is_some()) { + self.warn(STROKES); + } self.wrote_fixed = true; if self.brush_art(path, s, xy) { return; diff --git a/crates/eps/src/ai_editing/writer/brushes.rs b/crates/eps/src/ai_editing/writer/brushes.rs new file mode 100644 index 0000000000..2c281e702d --- /dev/null +++ b/crates/eps/src/ai_editing/writer/brushes.rs @@ -0,0 +1,558 @@ +//! Brushes: the document's brushes as the `.ai` reader reads them ([`crate::import`]'s `.ai` reader, +//! `brush.rs`), and the brush filter of a brushed stroke in an art style (`style.rs`). +//! +//! Each brush is a plug-in object (`%AI8_BeginPluginObject`, the plug-in's name, the brush's name, +//! its parameters as strings that `-` continues and `.` ends, `%AI8_EndPluginObject`), after the +//! brush manager's order of them all, in a non-printing block after the symbols. The art of a +//! Scatter or Art brush is a `%AI8_BeginBrushPattern` section (its name; a group of the art's frame, +//! an unpainted rectangle at the art's geometric bounds, and the art, or the art alone when it has no +//! width or height; its end) in a non-printing block before the symbols. Each brush is checked with +//! the reader's own reading ([`crate::import::read_brush_object`]) before it is written: one the +//! reader would read as another brush is left out. + +use std::collections::HashSet; +use std::fmt::Write; +use std::sync::Arc; + +use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Calligraphic, Colorization, Direction, Scatter, Variation}; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Affine, PathData, SubPath}; + +use super::{Writer, Xy}; +use crate::ai_ops; +use crate::ps; + +pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't stretch their art along the path from left to right at 100% unflipped without overlap, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps"; + +/// Most brushes written (the reader's limit). +const MAX_BRUSHES: usize = 4096; + +/// Most bytes of a plug-in object's parameters in one of its strings. +const CHUNK: usize = 60; + +/// A brush written as a plug-in object. +pub(super) struct BrushOut { + pub brush: Brush, + pub plugin: &'static str, + pub params: String, + /// The art its parameters name, as the reader reads it. + pub art: Option, + /// Its art section to write ([`Writer::brush_blocks`] writes it). + section: Option, + /// A path whose art style names it at its own settings is read with the brush on its stroke. + pub live: bool, +} + +/// A brush's art section to write: its name, the art and the space it is written in +/// ([`Writer::brush_art_section`]). +type SectionArt = (String, Node, Affine); + +/// The brushes written and their art. +#[derive(Default)] +pub(super) struct Brushes { + /// The document's brushes have been worked out. + done: bool, + pub written: Vec, + /// The art sections named so far. + sections: usize, + /// A brush of the document was left out. + left_out: bool, + /// How many objects were written with a brush on their stroke. + pub live_written: usize, + /// The brushes written as the reader reads them, for checking art styles; made once, when + /// the first style is checked ([`Writer::brushed_style_reads`]). + read: Option, +} + +/// A number with at most six decimals, as brush parameters hold factors. +fn num6(s: &mut String, v: f64) { + let v = if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 }; + if v == v.trunc() && v.abs() < 1e15 { + let _ = write!(s, "{}", v as i64); + return; + } + let start = s.len(); + let _ = write!(s, "{v:.6}"); + let keep = s[start..].trim_end_matches('0').trim_end_matches('.').len(); + s.truncate(start + keep); +} + +/// `vs` as [`num6`] numbers separated by spaces. +fn nums6(vs: &[f64]) -> String { + let mut s = String::new(); + for (i, v) in vs.iter().enumerate() { + if i > 0 { + s.push(' '); + } + num6(&mut s, *v); + } + s +} + +/// The code a brush's parameters give how a value varies, in the order of the Brush Options menu. +fn code(v: Variation) -> f64 { + match v { + Variation::Fixed => 0.0, + Variation::Random => 1.0, + Variation::Pressure => 2.0, + Variation::Tilt => 4.0, + Variation::Bearing => 5.0, + Variation::Rotation => 6.0, + } +} + +/// The colorization code of Scatter and Art brushes' parameters: `None` for one the reader doesn't read. +fn colorization_code(c: Colorization) -> Option { + match c { + Colorization::None => Some(0.0), + Colorization::Tints => Some(1.0), + Colorization::TintsAndShades | Colorization::HueShift { .. } => None, + } +} + +/// A Calligraphic brush's parameters: 1, the diameter, roundness and angle as (low, high) pairs, +/// the codes of how the diameter, roundness and angle vary, and 0. +fn calligraphic_params(c: &Calligraphic) -> String { + let [angle, roundness, size] = [c.angle, c.roundness, c.size]; + let [av, rv, dv] = c.variation; + let [am, rm, dm] = c.modes().map(code); + nums6(&[1.0, size - dv, size + dv, roundness - rv, roundness + rv, angle - av, angle + av, dm, rm, am, 0.0]) +} + +/// A Scatter brush's parameters, its art the section `art`: `None` for a colorization the reader +/// doesn't read. +fn scatter_params(s: &Scatter, art: &str) -> Option { + let f = |v: f64| v / 100.0; + let m = s.modes().map(code); + let numbers = [ + colorization_code(s.colorization)?, + f(s.size.0), + f(s.size.1), + m[0], + f(s.spacing.0), + f(s.spacing.1), + m[1], + f(s.scatter.0), + f(s.scatter.1), + m[2], + s.rotation.0, + s.rotation.1, + m[3], + f64::from(u8::from(s.rotation_relative_to_path)), + 0.0, + ]; + Some(format!("2 /{art}/ 5 0 0 0 / {}", nums6(&numbers))) +} + +/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read +/// (it reads an Art brush that stretches its art along the path from left to right at 100%, +/// unflipped, without overlap, in Tints or keeping its colors). +fn art_params(a: &ArtBrush, art: &str) -> Option { + let plain = a.direction == Direction::LeftToRight + && matches!(a.scale, ArtScale::Stretch) + && a.width == 100.0 + && a.width_mode.is_none() + && !a.flip_along + && !a.flip_across + && !a.overlap; + if !plain { + return None; + } + let c = colorization_code(a.colorization)?; + let numbers = [1.0, c, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]; + Some(format!("2 / {art}/ / / / / 0 0/ 5 0 0 0 / {}", nums6(&numbers))) +} + +/// Is `art` written as the reader reads it back, within a brush: paths and compound paths with +/// points and groups with members (not clipping, without paint of their own), each opaque in the +/// normal blending mode, without effects or opacity masks, with at most one fill and one plain +/// stroke (no brush, width profile, arrowheads, alignment off the path or dashes fitted to corners) +/// over it, each in a color or none? A path without points isn't written ([`Writer::plain_object`]). +fn plain_art(art: &Node) -> bool { + let mut plain = true; + art.walk(&mut |n| { + let kind = match &n.kind { + NodeKind::Path { path, guide: false, clipping: false, .. } => path.subpaths.iter().any(|sp| !sp.anchors.is_empty()), + NodeKind::Compound { .. } => super::compound_path(n).subpaths.iter().any(|sp| !sp.anchors.is_empty()), + NodeKind::Group { children, clip: false } => !children.is_empty(), + _ => false, + }; + let items = &n.appearance.items; + let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count(); + let strokes = items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); + let order = !matches!(items.as_slice(), [AppearanceItem::Stroke(_), AppearanceItem::Fill(_)]); + let quiet = items.iter().all(|i| { + i.effects().is_empty() + && i.opacity() >= 1.0 + && i.blend() == BlendMode::Normal + && matches!(i.paint(), Paint::Solid { .. } | Paint::None) + && !matches!(i, AppearanceItem::Stroke(s) if !vectorcraft_effects::stroke::is_plain(s)) + }); + // A group's own fills and strokes aren't written. + let group_paint = matches!(n.kind, NodeKind::Group { .. }) && items.iter().any(|i| !i.paint().is_none()); + plain &= kind + && !group_paint + && fills <= 1 + && strokes <= 1 + && order + && quiet + && n.appearance.effects.is_empty() + && n.extras().mask.is_none() + && n.opacity >= 1.0 + && n.blend == BlendMode::Normal; + }); + plain +} + +/// `art` (checked by [`plain_art`]) with its structure as the reader reads it back. A path, or a +/// compound path with the subpaths of all its members as one path ([`super::compound_path`]), is +/// written as one path when it has one subpath with points and as a compound path when it has more +/// ([`Writer::plain_object`]); the reader reads that compound path as one path for each subpath, +/// each with the object's paint. +fn as_read(art: &mut Node) { + let (subpaths, rule) = match &art.kind { + NodeKind::Path { path, rule, .. } => (path.subpaths.clone(), *rule), + NodeKind::Compound { rule, .. } => (super::compound_path(art).subpaths, *rule), + _ => { + if let Some(children) = art.children_mut() { + for c in children.iter_mut() { + as_read(Arc::make_mut(c)); + } + } + return; + } + }; + let mut subpaths: Vec = subpaths.into_iter().filter(|sp| !sp.anchors.is_empty()).collect(); + let path = |sp: SubPath| { + let mut p = Node::path(art.id, PathData::single(sp), Appearance::default()); + p.appearance = art.appearance.clone(); + p + }; + match subpaths.len() { + 0 => {} + 1 => { + let Some(sp) = subpaths.pop() else { return }; + art.kind = path(sp).kind; + if let NodeKind::Path { rule: r, .. } = &mut art.kind { + *r = rule; + } + } + _ => { + let children = subpaths.into_iter().map(|sp| Arc::new(path(sp))).collect(); + art.kind = NodeKind::Compound { children, rule }; + } + } +} + +/// Do brushes `a` and `b` have the same settings, within the written numbers' precision, whatever +/// their art and however their modes are given? +fn same_settings(a: &Brush, b: &Brush) -> bool { + let norm = |b: &Brush| -> serde_json::Value { + let mut b = b.clone(); + let none = || Node::group(NodeId(0), vec![]); + match &mut b.kind { + BrushKind::Calligraphic(c) => c.modes = Some(c.modes()), + BrushKind::Scatter(s) => { + s.modes = Some(s.modes()); + s.art = none(); + } + BrushKind::Art(a) => a.art = none(), + BrushKind::Pattern(_) | BrushKind::Bristle(_) => {} + } + serde_json::to_value(&b).unwrap_or(serde_json::Value::Null) + }; + super::styles::near(&norm(a), &norm(b)) +} + +/// A plug-in object: its plug-in's name, its name, and `params` in strings of at most [`CHUNK`] bytes. +fn plugin_object(plugin: &str, name: &str, params: &str) -> String { + let mut s = format!("%AI8_BeginPluginObject\n{}\n{}\n", ps::bytes(plugin.as_bytes()), ps::bytes(name.as_bytes())); + let chunks: Vec<&[u8]> = params.as_bytes().chunks(CHUNK).collect(); + if chunks.is_empty() { + s.push_str("() .\n"); + } + for (i, c) in chunks.iter().enumerate() { + let _ = writeln!(s, "{} {}", ps::bytes(c), if i + 1 == chunks.len() { "." } else { "-" }); + } + s.push_str("%AI8_EndPluginObject\n"); + s +} + +impl Writer<'_> { + /// Work out, once, which of the document's brushes are written: each one the reader reads back + /// with its settings. Their art is written with the brushes ([`Self::brush_blocks`]). + pub(super) fn brush_set(&mut self) { + if self.brush_set.done { + return; + } + self.brush_set.done = true; + let doc = self.doc; + let lib = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)).clone(); + if !self.brushes_live { + self.brush_set.left_out = !lib.is_empty(); + return; + } + // A name is taken by a brush written, as the reader takes it by a brush it keeps. A brush after + // another of its name isn't the one a path of that name is drawn with, so no path is written + // with it on its stroke. + let mut names: HashSet = HashSet::new(); + let mut seen: HashSet = HashSet::new(); + for b in lib { + let fits = !b.name.contains('/') && !names.contains(&b.name) && self.brush_set.written.len() < MAX_BRUSHES; + let first = seen.insert(b.name.clone()); + match fits.then(|| self.brush_out(&b)).flatten() { + Some(mut out) => { + names.insert(b.name.clone()); + out.live &= first; + self.brush_set.written.push(out); + } + None => self.brush_set.left_out = true, + } + } + // Read together, as one file's: the reader copies only so much art into one file's brushes. + let objects: Vec<(&str, &str, &str, Option<&Node>)> = + self.brush_set.written.iter().map(|b| (b.plugin, b.brush.name.as_str(), b.params.as_str(), b.art.as_ref())).collect(); + let kept = crate::import::brushes_kept(&objects); + if kept.iter().any(|k| !k) { + self.brush_set.left_out = true; + let mut keep = kept.into_iter(); + self.brush_set.written.retain(|_| keep.next().unwrap_or(false)); + } + } + + /// Brush `b` as it is written, with its art section's name, art and placement + /// ([`Self::brush_art_section`] writes it): `None` when the reader wouldn't read it back. + fn brush_out(&mut self, b: &Brush) -> Option { + let plugin = crate::import::brush_plugin_name(&b.kind); + let section = format!("Brush Art {}", self.brush_set.sections + 1); + // The art as the reader reads it: a section is read with y down, and an Art brush's art is + // turned over after that (its section's y up is the right of the path). + let (params, art) = match &b.kind { + BrushKind::Calligraphic(c) => (calligraphic_params(c), None), + BrushKind::Scatter(s) => (scatter_params(s, §ion)?, Some((s.art.clone(), Affine::FLIP_Y))), + BrushKind::Art(a) => (art_params(a, §ion)?, Some((a.art.clone(), Affine::IDENTITY))), + BrushKind::Pattern(_) | BrushKind::Bristle(_) => return None, + }; + let read_art = match &art { + Some((a, to_section)) => { + if !plain_art(a) { + return None; + } + let mut read = a.clone(); + if *to_section == Affine::IDENTITY { + read.transform(Affine::FLIP_Y, false); + } + as_read(&mut read); + Some(read) + } + None => None, + }; + let read = crate::import::read_brush_object(plugin, &b.name, ¶ms, read_art.as_ref())?; + if !same_settings(&read.brush, b) { + return None; + } + if art.is_some() { + self.brush_set.sections += 1; + } + let section = art.map(|(a, to_section)| (section, a, to_section)); + Some(BrushOut { brush: b.clone(), plugin, params, art: read_art, section, live: read.live }) + } + + /// A brush's art section `name`: `art` written with `to_section` taking the document's space to + /// the section's (y up), from the state a definition starts in. Its objects are counted as the + /// reader counts them ([`Self::take_objects`]: the group, the frame and the art). `None` when + /// writing it warns: what it took is given back, and when it didn't fit within the object limit, + /// the limit's warning is kept. + fn brush_art_section(&mut self, name: &str, art: &Node, to_section: Affine) -> Option { + let before = self.charges(); + // The art's own warnings, apart from those the document already has (a warning is kept once). + let warnings = std::mem::take(&mut self.warnings); + let saved = ( + std::mem::take(&mut self.body), + (self.to_art, self.grad_space, self.canvas), + self.state.take(), + self.gs.replace(Xy::OPAQUE), + self.clip.take(), + (self.block_start, self.block_end), + // The art is the brush's own: not on `%_` lines, in a drawn look or in a symbol that + // first uses the brush. + (std::mem::take(&mut self.hidden), std::mem::take(&mut self.in_look), std::mem::take(&mut self.in_symbol)), + ); + (self.to_art, self.grad_space, self.canvas, self.block_end) = (to_section, to_section, to_section, usize::MAX); + self.state(false, false); + // The art in a group after its frame, an unpainted rectangle around it. Art without width or + // height (a straight line) is written alone: the reader keeps a frame of no area as art. + let frame = art + .geometric_bounds() + .filter(|b| b.width() > 0.0 && b.height() > 0.0) + .and_then(|b| vectorcraft_geom::shapes::rectangle(b).subpaths.first().cloned()); + match frame { + Some(frame) if self.take_objects(2) => { + self.line("u\n0 XR"); + ai_ops::subpath(&mut self.body, to_section, &frame); + self.line("n"); + self.nest += 1; + self.node(art); + self.nest -= 1; + self.line("U"); + } + Some(_) => {} + None => { + self.nest += 1; + self.node(art); + self.nest -= 1; + } + } + let body; + ( + body, + (self.to_art, self.grad_space, self.canvas), + self.state, + self.gs, + self.clip, + (self.block_start, self.block_end), + (self.hidden, self.in_look, self.in_symbol), + ) = saved; + let text = std::mem::replace(&mut self.body, body); + if !std::mem::replace(&mut self.warnings, warnings).is_empty() { + self.give_back(before, &text); + if self.left_out_since(before) { + self.warn(super::OBJECT_LIMIT); + } + return None; + } + self.keep_since(before); + Some(format!("%AI8_BeginBrushPattern\n{}\n{text}%AI8_EndBrushPattern\n", ps::bytes(name.as_bytes()))) + } + + /// The written brush named `name` that a path can be written with live. + pub(super) fn live_brush(&self, name: &str) -> Option<&BrushOut> { + self.brush_set.written.iter().find(|b| b.live && b.brush.name == name) + } + + /// The basic filter of stroke `st` with the brush it names: the brush's plug-in as the filter, + /// its settings for the object, the stroke's style and the brush by name. `None` when the brush + /// isn't written live. + pub(super) fn brush_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> { + let name = st.brush.as_deref()?; + self.brush_set(); + let (plugin, kind) = self.live_brush(name).map(|b| (b.plugin, b.brush.kind.clone()))?; + if !matches!(st.paint, Paint::Solid { .. }) || st.overprint { + return None; + } + let ops = self.color_ops(&st.paint, true); + let mut d = String::new(); + let title = match &kind { + BrushKind::Calligraphic(c) => { + let modes = c.modes().map(code); + for (key, value, variation, mode) in [ + ("Diameter", c.size, c.variation[2], modes[2]), + ("Roundness", c.roundness, c.variation[1], modes[1]), + ("Angle", c.angle, c.variation[0], modes[0]), + ] { + let _ = writeln!(d, "{} /Real ({key}) ,", ps::num(value)); + let _ = writeln!(d, "{} /Real ({key}Variation) ,", ps::num(variation)); + let _ = writeln!(d, "{} /Int ({key}DependsOn) ,", mode as i64); + } + d.push_str("0 /Int (AngleRelativeTo) ,\n"); + "(Calligraphic Brush) /Title ,\n" + } + BrushKind::Art(_) => { + d.push_str("15 /Int (Adobe Effect Expand Before Version) ,\n"); // brand-ok: the entry's name in the files + for (key, value, kind) in [ + ("StartSegmentLength", "0", "Real"), + ("EndSegmentLength", "0", "Real"), + ("Counterclockwise", "0", "Int"), + ("MinScaling", "1", "Real"), + ("MaxScaling", "1", "Real"), + ("Colorization", "1", "Int"), + ("Spacing", "0", "Real"), + ("Adjustment", "1", "Int"), + ("Proportional", "0", "Bool"), + ("Segmented", "0", "Bool"), + ("UpsideDown", "0", "Int"), + ("FoldAdjustment", "1", "Bool"), + ("ScaleYDependsOn", "0", "Int"), + ("Scaling", "1", "Real"), + ] { + let _ = writeln!(d, "{value} /{kind} ({key}) ,"); + } + "(Art Brush) /Title ,\n" + } + _ => return None, + }; + super::styles::basic(s, plugin, true, visible); + s.push_str(title); + s.push_str("2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n"); + s.push_str(&d); + s.push_str("/StrokeStyle : 0 R\n"); + s.push_str(&ops); + s.push_str("0 1 0 0 0 Xy\n"); + ai_ops::line_style(s, st); + s.push_str("0 XR\n1 0 Xd\n/Def ; \n (StrokeStyle) ,\n"); + let quoted = ps::bytes(name.as_bytes()); + let _ = + write!(s, "/GObjRef : {quoted} {} /PluginObject ; (Brush) ,\n{quoted} /UnicodeString (DisplayString) ,\n", ps::bytes(plugin.as_bytes())); + s.push_str(super::styles::END_BASIC); + Some(()) + } + + /// Does the reader read the brushed strokes of style text `text` with their brushes on them? + pub(super) fn brushed_style_reads(&mut self, text: &str) -> bool { + self.brush_set(); + let section = super::styles::known_style("x", text); + let written = &self.brush_set.written; + let read = self.brush_set.read.get_or_insert_with(|| { + let brushes: Vec<(&str, &str, &str, Option<&Node>)> = + written.iter().map(|b| (b.plugin, b.brush.name.as_str(), b.params.as_str(), b.art.as_ref())).collect(); + crate::import::BrushesRead::new(&brushes) + }); + read.style_reads(section.as_bytes(), "x") + } + + /// The brushes' art in a non-printing block, then the brush manager's order and the brushes in + /// two more, when the document has brushes of its own or an object is written with a brush on + /// its stroke (a file without brushes opens with a new document's). Warns when a brush is left + /// out. + pub(in crate::ai_editing) fn brush_blocks(&mut self) -> (String, String) { + let stored = self.doc.unknown.contains_key(vectorcraft_brush::DOC_KEY); + if !stored && self.brush_set.live_written == 0 { + return (String::new(), String::new()); + } + self.brush_set(); + // The art is written now that the file holds it, after the layers' objects. A brush whose art + // doesn't fit is left out; a path written with it on its stroke opens as its drawn look. + let mut sections = String::new(); + for mut b in std::mem::take(&mut self.brush_set.written) { + if let Some((name, art, to_section)) = b.section.take() { + let Some(text) = self.brush_art_section(&name, &art, to_section) else { + self.brush_set.left_out = true; + continue; + }; + sections.push_str(&text); + } + self.brush_set.written.push(b); + } + if self.brush_set.left_out { + self.warn(BRUSHES); + } + // On a large canvas an empty panel is still written: the manager alone, with no art to scale. + let empty = self.brushes.as_ref().is_some_and(|l| l.is_empty()); + if !self.brushes_live && !(stored && empty) { + return (String::new(), String::new()); + } + let art = if sections.is_empty() { String::new() } else { format!("%AI5_Begin_NonPrinting\nNp\n{sections}%AI5_End_NonPrinting--\n") }; + let manager = crate::import::BRUSH_MANAGER; + let order: String = self.brush_set.written.iter().map(|b| format!(" {}/ {}/", b.plugin, b.brush.name)).collect(); + let mut objects = format!("%AI5_Begin_NonPrinting\nNp\n{}%AI5_End_NonPrinting--\n", plugin_object(manager, manager, &order)); + if !self.brush_set.written.is_empty() { + objects.push_str("%AI5_Begin_NonPrinting\nNp\n"); + for b in &self.brush_set.written { + objects.push_str(&plugin_object(b.plugin, &b.brush.name, &b.params)); + } + objects.push_str("%AI5_End_NonPrinting--\n"); + } + (art, objects) + } +} diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index c28c538bcf..29eb2bf910 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -40,8 +40,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { } /// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), -/// by id: paths and compound paths with points and groups, with live effects or linked to a graphic -/// style, without Opacity & Mask Define Knockout Shape (the reader takes an object's transparency +/// by id: paths and compound paths with points and groups, with live effects, linked to a graphic +/// style or (paths and compound paths) with a brush on a stroke, without Opacity & Mask Define +/// Knockout Shape (the reader takes an object's transparency /// from its look, which doesn't carry it). A group's members are paths, compound paths or such /// groups, none with effects or anything baking changes. pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { @@ -76,7 +77,8 @@ fn bakes_members(n: &Node) -> bool { /// Whether `n` may be written with an art style ([`styled_objects`]). fn candidate(n: &Node) -> bool { - let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some(); + let brushed = brushed(n) && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some() || brushed; if !styled || n.knockout_shape { return false; } @@ -88,6 +90,12 @@ fn candidate(n: &Node) -> bool { } } +/// Does `n` have a stroke with a brush that paints, one that its art style holds ([`Writer::style_text`] +/// leaves out what paints nothing)? +fn brushed(n: &Node) -> bool { + n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none())) +} + /// Does `path` write anything ([`Writer::plain_object`] skips subpaths without points)? fn has_points(path: &vectorcraft_geom::PathData) -> bool { path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) @@ -144,7 +152,7 @@ impl Styles { } /// One style as the reader's section holds it: `/KnownStyle :`, `(name) /Name ,`, its text. -fn known_style(name: &str, text: &str) -> String { +pub(super) fn known_style(name: &str, text: &str) -> String { format!("/KnownStyle :\n{} /Name ,\n{text}", ps::bytes(name.as_bytes())) } @@ -172,7 +180,7 @@ const NO_LINE: &str = "0 J 0 j 1 w 10 M []0 d\n"; /// A basic filter's start: its name and kind numbers (`1 0` for an effect, `0 0` for the /// style's own filters), `/Visible` and the dictionary's start. -fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { +pub(super) fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { let _ = write!(s, "/BasicFilter :\n{} {} 0 /Filter ,\n{} /Visible ,\n", ps::bytes(name.as_bytes()), u8::from(effect), u8::from(visible)); } @@ -180,7 +188,7 @@ fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { const CONDUIT: &str = "/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n"; /// A basic filter's end. -const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; +pub(super) const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; /// The blend filter of transparency `xy`. fn blend_filter(s: &mut String, xy: Xy) { @@ -228,7 +236,7 @@ fn flag(p: &Value, key: &str) -> Option { impl Writer<'_> { /// What `paint` writes with [`Self::color`] (a color, a spot or global color), as a string. - fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { + pub(super) fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { let saved = std::mem::take(&mut self.body); self.color(paint, stroke); std::mem::replace(&mut self.body, saved) @@ -267,9 +275,12 @@ impl Writer<'_> { } /// A stroke's basic filter: a color, its line style, and its width profile as the arrays - /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`. `None` for - /// other paint. + /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`; a stroke with a + /// brush, the brush's filter ([`Self::brush_filter`]). `None` for other paint. fn stroke_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> { + if st.brush.is_some() { + return self.brush_filter(s, st, visible); + } if !matches!(st.paint, Paint::Solid { .. }) || st.overprint { return None; } @@ -652,17 +663,19 @@ impl Writer<'_> { && let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone()) && object_reads(orig, &text) { - self.styles.links_written += 1; return Some(name); } - if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) { + let brushed = brushed(orig); + if (orig.appearance.effects.is_empty() && !brushed) || !own_style_holds(&orig.appearance) { return None; } let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); let saved = (graded.then(|| self.gradient_mark()), self.warnings.len()); let path = drawn_outline(n, orig); let rule = object_rule(orig); - let text = self.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))).filter(|t| object_reads(orig, t)); + let text = self + .style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))) + .filter(|t| object_reads(orig, t) && (!brushed || self.brushed_style_reads(t))); let Some(text) = text else { if let Some(mark) = saved.0 { self.gradient_rollback(mark); @@ -693,7 +706,8 @@ impl Writer<'_> { /// compound path's points painted with nothing, or with its gradient fill when its style has /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. /// The look's group and the object itself are counted first ([`Self::take_objects`]): without - /// room for them, nothing is written. + /// room for them, nothing is written, and the object counts neither as written with its graphic + /// style nor as written with a brush on its stroke. pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { let own = match &orig.kind { NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), @@ -704,6 +718,12 @@ impl Writer<'_> { if !self.take_objects(1 + own) { return; } + let linked = orig.extras().graphic_style.and_then(|id| self.styles.panel.get(&id)).is_some_and(|p| p == name); + if linked { + self.styles.links_written += 1; + } else if brushed(orig) { + self.brush_set.live_written += 1; + } self.state(orig.locked, !orig.visible); let xy = Xy::of(orig); let was = self.in_look; @@ -834,8 +854,12 @@ fn own_style_holds(ap: &Appearance) -> bool { ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only) } -/// Does the reader read graphic style `g` from style text `text` as it is? +/// Does the reader read graphic style `g` from style text `text` as it is? It leaves out a graphic +/// style with a brush on a stroke that paints. fn panel_reads(g: &GraphicStyle, text: &str) -> bool { + if g.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none())) { + return false; + } let section = known_style("x", text); let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false }; // The opacity within the written number's precision. @@ -918,7 +942,7 @@ fn normalized_params(e: &Effect) -> Value { const NEAR: f64 = 6e-3; /// JSON values equal but for numbers within the written numbers' precision. -fn near(a: &Value, b: &Value) -> bool { +pub(super) fn near(a: &Value, b: &Value) -> bool { match (a, b) { (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { (Some(x), Some(y)) => (x - y).abs() <= NEAR, diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index dc335bf1a1..b750efbc20 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -86,8 +86,8 @@ pub use ai::NameEncoding; #[cfg(test)] pub(crate) use ai::objects_read; pub(crate) use ai::{ - MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, - symbol_art_member, warp_number, + BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, + read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index be2d679a78..2d6c4a533d 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -34,6 +34,7 @@ use std::io::Read as _; use super::lex::find; use crate::ps; +pub(crate) use brush::{BrushesRead, MANAGER_NAME as BRUSH_MANAGER, brushes_kept, plugin_name as brush_plugin_name, read_brush_object}; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; #[cfg(test)] pub(crate) use envelope::MAX_CHECKED; diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index dc94ff7927..5a5db47114 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -563,3 +563,127 @@ pub(super) fn without_frame(art: Node) -> Node { } out } + +/// The plug-in name the objects of brushes of kind `kind` have, for writers. +pub(crate) fn plugin_name(kind: &BrushKind) -> &'static str { + let tool = match kind { + BrushKind::Calligraphic(_) => Tool::Calligraphic, + BrushKind::Scatter(_) => Tool::Scatter, + BrushKind::Art(_) => Tool::Art, + BrushKind::Pattern(_) => Tool::Pattern, + BrushKind::Bristle(_) => Tool::Bristle, + }; + TOOLS.iter().find(|(_, t)| *t == tool).map_or("", |(n, _)| *n) +} + +/// The plug-in name of the brush manager's order, for writers. +pub(crate) const MANAGER_NAME: &str = MANAGER; + +/// A brush object as the reader reads it, for writers that check a brush before they write it. +#[derive(Clone, Debug)] +pub(crate) struct BrushRead { + /// The brush the Brushes panel gets. + pub brush: Brush, + /// A path whose art style names the brush, at the brush's own settings, opens with the brush on + /// its stroke: a Calligraphic brush, or an Art brush whose art an Art brush draws as the file + /// does ([`art_bends`]) and whose numbers are those of a brush that stretches its art along the + /// path, unflipped, at 100% in Tints. + pub live: bool, +} + +/// The brush named `name` whose object has the plug-in name `plugin` and the parameters `params`, +/// with `art` as the art its parameters name (as the reader reads that art), as the reader reads +/// it: `None` when the reader doesn't make a brush of it. +pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Option<&Node>) -> Option { + let draft = parse_tool_object(tool(plugin)?, name, params).ok()?; + let mut sections = HashMap::new(); + if let (DraftKind::Scatter { art: section, .. } | DraftKind::Art { art: section, .. }, Some(a)) = (&draft.kind, art) { + sections.insert(section.clone(), a.clone()); + } + let drafts = [draft]; + let live = kept(&drafts, §ions).first().copied().unwrap_or(false) + && match &drafts[0].kind { + DraftKind::Calligraphic(_) => true, + DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, §ions).contains(section), + _ => false, + }; + let [draft] = drafts; + let (mut brushes, _) = finish_brushes(vec![draft], §ions, None); + Some(BrushRead { brush: brushes.pop()?, live }) +} + +/// Which of the brush objects `brushes` (plug-in name, name, parameters, the art each names as the +/// reader reads it) the reader makes into brushes when one file holds them all: not one it can't read, +/// nor one past the art the reader copies into one file's brushes ([`kept`]). +pub(crate) fn brushes_kept(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Vec { + let mut drafts = vec![]; + let mut at = vec![]; + let mut sections = HashMap::new(); + for (i, (plugin, name, params, art)) in brushes.iter().enumerate() { + let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue }; + if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) { + sections.insert(s.clone(), (*a).clone()); + } + drafts.push(d); + at.push(i); + } + let mut out = vec![false; brushes.len()]; + for (i, k) in at.into_iter().zip(kept(&drafts, §ions)) { + if let Some(slot) = out.get_mut(i) { + *slot = k; + } + } + out +} + +/// Brush objects a writer writes, as the reader reads them together from one file, for checking +/// the art styles that name them ([`Self::style_reads`]). +pub(crate) struct BrushesRead { + drafts: Vec, + /// The brush a stroke that names a brush is drawn with, by name: the first one kept + /// ([`kept`]), as an index in [`Self::drafts`]. + by_name: HashMap, + /// The art an Art brush draws as the file does ([`bending_art`]). + bending: HashSet, +} + +impl BrushesRead { + /// `brushes`: the brush objects written (plug-in name, name, parameters) and the art each names, + /// as the reader reads it. + pub(crate) fn new(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Self { + let mut drafts = vec![]; + let mut sections = HashMap::new(); + for (plugin, name, params, art) in brushes { + let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue }; + if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) { + sections.insert(s.clone(), (*a).clone()); + } + drafts.push(d); + } + let mut by_name = HashMap::new(); + for (i, (d, k)) in drafts.iter().zip(kept(&drafts, §ions)).enumerate() { + if k { + by_name.entry(d.name.clone()).or_insert(i); + } + } + let bending = bending_art(&drafts, §ions); + Self { drafts, by_name, bending } + } + + /// Does the reader read every stroke of art style `style` in `section` (the text between + /// `%AI9_BeginArtStyles` and its end) that names a brush with that brush on it, at the brush's + /// own settings? A style the reader doesn't read, or that names a brush no object gives, or an + /// Art brush in a style with effects, isn't read so. + pub(crate) fn style_reads(&self, section: &[u8], style: &str) -> bool { + use super::style::StyleItem; + let styles = super::style::art_styles(section); + let Some(st) = styles.get(style).filter(|st| st.plain) else { return false }; + st.items.iter().all(|item| match item { + StyleItem::Stroke { brush: Some((name, settings)), .. } => { + let Some(draft) = self.by_name.get(name).and_then(|i| self.drafts.get(*i)) else { return false }; + draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art) && !st.effects.is_empty()) + } + _ => true, + }) + } +} diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 82f16c04a9..3a93af7419 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1260,8 +1260,11 @@ pub(crate) fn read_style_list(section: &[u8]) -> Vec { /// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`], /// with a style's one gradient fill as `own_gradient`, the object's own gradient, as [`styled`] reads -/// it on objects other than type. `None` when the section doesn't define it, the reader doesn't read -/// it, or it paints with a pattern, a brush or a gradient that isn't the object's own. +/// it on objects other than type. A stroke that names a brush is read with that brush's name, as +/// [`styled`] reads it on a path or compound path when the Brushes panel keeps that brush at those +/// settings, which [`crate::import::ai::brush::BrushesRead::style_reads`] checks. `None` when the section +/// doesn't define it, the reader doesn't read it, or it paints with a pattern or a gradient that +/// isn't the object's own. pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&Paint>) -> Option { use crate::import::ai::style::StyleItem; let mut st = crate::import::ai::style::art_styles(section).remove(name)?; @@ -1273,7 +1276,21 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P } } } - let appearance = appearance_from_art_style(&st)?; + // The brush names, by item; `appearance_from_art_style` reads a style's items in order. + let brushes: Vec> = st + .items + .iter_mut() + .map(|item| match item { + StyleItem::Stroke { brush, .. } => brush.take().map(|(name, _)| name), + _ => None, + }) + .collect(); + let mut appearance = appearance_from_art_style(&st)?; + for (item, brush) in appearance.items.iter_mut().zip(brushes) { + if let (AppearanceItem::Stroke(s), Some(brush)) = (item, brush) { + s.brush = Some(brush); + } + } Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency }) } diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 87c11e1826..6a75837733 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -828,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()?; @@ -877,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; @@ -931,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")?; @@ -942,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)?; @@ -1203,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/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 65a30742e0..758fc67716 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -404,7 +404,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "view.actualSize", "Actual Size", "Cmd+1", - "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling); the same at every UI Scaling", ), ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), ( @@ -1371,25 +1371,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 +1407,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 +1455,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 +1467,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/stencil.rs b/crates/pathops/src/addons/stencil.rs index 437433e40c..a5412fa328 100644 --- a/crates/pathops/src/addons/stencil.rs +++ b/crates/pathops/src/addons/stencil.rs @@ -146,7 +146,7 @@ fn choose(contours: &[Contour], spec: BridgeSpec, work: usize) -> Result<(Vec
0 { return Err(PathOpsError::TooComplex); } picker.spread(spec.count); diff --git a/crates/pathops/src/addons/stencil/tests.rs b/crates/pathops/src/addons/stencil/tests.rs index 48d2f8148e..93ea92cdde 100644 --- a/crates/pathops/src/addons/stencil/tests.rs +++ b/crates/pathops/src/addons/stencil/tests.rs @@ -210,6 +210,37 @@ fn bridges_found_before_the_budget_runs_out_are_kept() { assert!(matches!(choose(&found, spec(4.0, 1, BridgeDirection::Vertical), least - 1), Err(PathOpsError::TooComplex))); } +#[test] +fn joined_art_is_kept_when_lines_in_one_direction_run_out() { + // A ring whose thin left bar has edges of many short zigzag segments: lines across it in the + // horizontal direction look at many more segments than vertical lines across the top bar. + let zigzag = |x: f64, from: f64, to: f64, out: &mut Vec| { + let n = ((to - from).abs() / 0.05) as usize; + out.extend((1..n).map(|i| Point::new(x + if i % 2 == 1 { 0.02 } else { 0.0 }, from + (to - from) * i as f64 / n as f64))); + }; + let mut outer = vec![Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)]; + zigzag(0.0, 100.0, 0.0, &mut outer); + let mut hole = vec![Point::new(10.0, 20.0), Point::new(170.0, 20.0), Point::new(170.0, 80.0), Point::new(10.0, 80.0)]; + zigzag(9.98, 80.0, 20.0, &mut hole); + let art = PathData::new(vec![SubPath::polyline(&outer, true), SubPath::polyline(&hole, true)]); + let found = contours(&art, 4.0).unwrap(); + let shortest = spec(4.0, 1, BridgeDirection::Shortest); + let views = (0..180).step_by(SHORTEST_STEP_DEG).count(); + // A share of the work that runs out in the horizontal direction while another direction joins + // the island. + let share = (1..5000) + .find(|&budget| { + let all: Vec = + (0..180).step_by(SHORTEST_STEP_DEG).map(|d| View::new(&found, (d as f64).to_radians(), 4.0, budget).unwrap()).collect(); + let mut picker = Picker::new(&found, &all, 4.0); + picker.connect(); + all.iter().any(View::exhausted) && picker.unjoined() == 0 + }) + .expect("a share of the work that runs out with the island joined"); + let (bridges, unjoined) = choose(&found, shortest, share * views).unwrap(); + assert_eq!((bridges.len(), unjoined), (1, 0)); +} + #[test] fn curves_are_followed_within_a_twentieth_of_the_width() { let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.001)); diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index ffc185a575..d978680d67 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -35,6 +35,7 @@ mod builder; mod cid; mod device; mod entry; +mod reshaped; mod state; #[cfg(test)] @@ -42,8 +43,8 @@ pub(crate) use cid::{CidText, font_chars, identify_glyph, unify_radical}; #[cfg(any(test, feature = "read-count"))] pub use entry::imports_of; pub use entry::{ - INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, - import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, + INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, RESHAPED_TEXT_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, + holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, }; #[cfg(test)] pub(crate) use entry::{TEST_MAX_OBJECTS, import_with_reader_options, read}; diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 945c082a26..6d47168716 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -1,9 +1,10 @@ use super::cid::{CidText, FontInfo, comparable, font_chars, identify_glyph, matching_face, replacement, unify_radical}; use super::entry::{ - MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, TEXT_OUTLINES, TOO_MANY_OBJECTS, UNMATCHED_GLYPHS_NOTE, + MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, RESHAPED_TEXT_NOTE, TEXT_OUTLINES, TOO_MANY_OBJECTS, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, max_objects, }; -use super::state::{Builder, Frame, FrameKind, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer}; +use super::reshaped; +use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer}; use super::{ Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget, FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint, @@ -22,6 +23,7 @@ impl<'p> Builder<'p> { page: Rect::ZERO, glyphs: None, text: None, + text_glyphs: vec![], text_as, actual: Default::default(), images: HashMap::new(), @@ -101,6 +103,7 @@ impl<'p> Builder<'p> { self.blend = BlendMode::Normal; self.glyphs = None; self.text = None; + self.text_glyphs.clear(); self.mask = None; self.masked.clear(); self.marked.clear(); @@ -376,18 +379,56 @@ impl<'p> Builder<'p> { } pub(super) fn flush_text(&mut self) { - let Some(mut line) = self.text.take() else { return }; + if self.end_text() { + self.flush_glyphs(); + } + } + + /// Finish the open line of type. `true`: it was kept as outlines instead ([`reshaped`]), which + /// are left open as the glyph run for an outline drawn next to join. + pub(super) fn end_text(&mut self) -> bool { + let glyphs = std::mem::take(&mut self.text_glyphs); + let Some(mut line) = self.text.take() else { return false }; let held = std::mem::take(&mut line.held); + // Type the file names itself (ActualText) is in reading order as it stands. + let checked = !glyphs.is_empty() && line.mapped(); + let mut outlined = false; if let Some((t, opacity, facts)) = line.finish() { - let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); - n.opacity = opacity; - self.lines.insert(n.id, facts); - self.push_node(n); + if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) { + outlined = true; + for g in glyphs { + let same = self.glyphs.as_ref().is_some_and(|r| { + r.paint == g.paint + && r.opacity == g.opacity + && r.overprint == g.overprint + && r.stroke.is_some() == g.stroke.is_some() + && r.stroke.as_ref().map(|s| s.line_width) == g.stroke.as_ref().map(|s| s.line_width) + && (r.scale - g.scale).abs() < 1e-9 + }); + match &mut self.glyphs { + Some(r) if same => r.path.extend(g.path.iter()), + _ => { + self.flush_glyphs(); + self.glyphs = Some(g); + } + } + } + self.warn(RESHAPED_TEXT_NOTE); + } else { + let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); + n.opacity = opacity; + self.lines.insert(n.id, facts); + self.push_node(n); + } + } + if outlined && !held.is_empty() { + self.flush_glyphs(); } // The strokes of its glyphs' outlines, right after it ([`import_lines`] makes them its stroke). for n in held { self.push_node(n); } + outlined } /// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph @@ -737,6 +778,27 @@ impl<'p> Builder<'p> { n } + /// Glyph `o` of the open line of type, placed by `m` and drawn with `drawn` (its paint, and its + /// stroke when it is stroked), as the outline it is kept as if the line is + /// ([`Self::end_text`]). Nothing for a glyph that shows nothing. + fn keep_glyph( + &mut self, + o: &craft_pdf::interpret::font::OutlineGlyph, + m: Affine, + drawn: Option<(Paint, Option)>, + opacity: f32, + scale: f64, + overprint: bool, + ) { + let Some((paint, stroke)) = drawn else { return }; + let mut path = o.outline(); + if path.elements().is_empty() { + return; + } + path.apply_affine(m); + self.text_glyphs.push(GlyphRun { path, paint, opacity, stroke, scale, overprint }); + } + /// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps /// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text, /// type with neither fill nor stroke. @@ -794,8 +856,11 @@ impl<'p> Builder<'p> { return false; } let width = o.advance_width().filter(|w| w.is_finite()); - // Set vertically (WMode 1): no horizontal advance. - let upright = width.is_some_and(|w| w.abs() <= 1.0); + // Set vertically (WMode 1): no horizontal advance. Not in a font the file says is written + // horizontally: there a glyph without an advance is a mark over the letter before it (a + // vowel sign, an anusvara), which stays on its line (#5235). + let horizontal = self.fonts.get(&key).is_some_and(|f| f.vertical.is_none()); + let upright = !horizontal && width.is_some_and(|w| w.abs() <= 1.0); let mut info = self.font_name(key, o); // A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which // style of the installed family it is, so the name decides. @@ -841,6 +906,8 @@ impl<'p> Builder<'p> { } None => (None, 1.0), }; + // What the glyph is drawn with as an outline, should its line be kept so. + let drawn = paint.clone().map(|p| (p, stroke.cloned())); let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); let look = Look { font: key, @@ -879,6 +946,7 @@ impl<'p> Builder<'p> { && look.fill.is_none() && line.stroke_last(key, at, s.clone(), overprint) { + self.keep_glyph(o, m, drawn, opacity, scale, overprint); return true; } if !self.text.as_mut().is_some_and(place) { @@ -889,6 +957,9 @@ impl<'p> Builder<'p> { } // Its ink: a stroke the file draws as outlines of the glyphs is told by it. let mut ink = o.outline(); + if top.is_none() { + self.keep_glyph(o, m, drawn, opacity, scale, overprint); + } if let Some(line) = &mut self.text && !ink.elements().is_empty() { diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index c21e51dbb5..b39905d6e0 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -191,7 +191,8 @@ impl<'a> Device<'a> for Builder<'_> { if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) { return; } - self.flush_text(); + // A line kept as outlines leaves them open: this glyph joins them. + self.end_text(); let mut bp = o.outline(); bp.apply_affine(transform * glyph_transform); let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true)); diff --git a/crates/pdf/src/import/entry.rs b/crates/pdf/src/import/entry.rs index 38f22689a8..8503b4fb20 100644 --- a/crates/pdf/src/import/entry.rs +++ b/crates/pdf/src/import/entry.rs @@ -171,14 +171,22 @@ pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's /// Warning: glyphs that differ from the installed font of their name were kept as outlines. pub const UNMATCHED_GLYPHS_NOTE: &str = "text whose glyphs differ from the installed font of the same name was kept as outlines"; +/// Warning: type in a script whose letters reorder and join (Indic scripts) that, set again from +/// its characters, wouldn't draw the glyphs where the file drew them was kept as outlines (#5235). +pub const RESHAPED_TEXT_NOTE: &str = + "text that would not look as it does in the file if set as type (a script whose letters join) was kept as outlines"; + /// The end of the warning that glyphs whose characters the file doesn't name were kept as /// outlines, after "text in" and the font's name. pub const UNNAMED_CHARACTERS_NOTE: &str = "whose characters the file doesn't name (U+FFFD) was kept as outlines"; /// Is `note` one of the warnings that glyphs were kept as outlines ([`OUTLINED_TEXT_NOTE`], -/// [`UNMATCHED_GLYPHS_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])? +/// [`UNMATCHED_GLYPHS_NOTE`], [`RESHAPED_TEXT_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])? pub fn is_outlines_note(note: &str) -> bool { - note == OUTLINED_TEXT_NOTE || note == UNMATCHED_GLYPHS_NOTE || note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE) + note == OUTLINED_TEXT_NOTE + || note == UNMATCHED_GLYPHS_NOTE + || note == RESHAPED_TEXT_NOTE + || note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE) } /// Is `n` glyphs kept as outlines (a path the import names ``)? diff --git a/crates/pdf/src/import/reshaped.rs b/crates/pdf/src/import/reshaped.rs new file mode 100644 index 0000000000..77d70dfc1c --- /dev/null +++ b/crates/pdf/src/import/reshaped.rs @@ -0,0 +1,53 @@ +//! Type in a script whose letters reorder and join as they are set (#5235). A PDF draws such text +//! as glyphs already shaped: a vowel sign before the consonant it follows in reading order, a +//! conjunct as one glyph that the file may not name, a mark placed over its base. Read glyph by +//! glyph, the characters don't always make the text that was set, and type made from them draws +//! other glyphs in other places. A line of such type is therefore set again and compared with what +//! the file drew; one that would look different keeps the file's glyphs as outlines. + +use kurbo::{Rect, Shape}; +use vectorcraft_doc::TextObject; + +/// Does `text` have a character of a script whose letters reorder and join into conjuncts: the +/// Brahmic scripts of U+0900 to U+0DFF (Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, +/// Telugu, Kannada, Malayalam, Sinhala)? +pub(super) fn reshapes(text: &str) -> bool { + text.chars().any(|c| ('\u{0900}'..='\u{0DFF}').contains(&c)) +} + +/// Would type `t` draw its glyphs where the file drew them (`ink`: each glyph's ink box in +/// document space)? As many glyphs with ink, each within 3 % of the type's size of one of the +/// file's. +pub(super) fn draws_as(t: &TextObject, ink: &[Rect]) -> bool { + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let mut set: Vec = + laid.glyphs.iter().filter(|g| !g.outline.elements().is_empty()).map(|g| (t.xf * g.outline.clone()).bounding_box()).collect(); + if set.len() != ink.len() { + return false; + } + let size = t.runs.iter().map(|r| r.style.size).fold(0.0, f64::max); + let tol = size * 0.03; + if !tol.is_finite() || set.iter().chain(ink).any(|r| !(r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite())) { + return false; + } + set.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + let mut used = vec![false; set.len()]; + // Each of the file's glyphs takes one the type draws in its place: looked for among those + // whose left edge is as near. + ink.iter().all(|r| { + let from = set.partition_point(|s| s.x0 < r.x0 - tol); + let found = set + .iter() + .zip(used.iter_mut()) + .skip(from) + .take_while(|(s, _)| s.x0 <= r.x0 + tol) + .find(|(s, used)| !**used && (s.y0 - r.y0).abs() <= tol && (s.x1 - r.x1).abs() <= tol && (s.y1 - r.y1).abs() <= tol); + match found { + Some((_, used)) => { + *used = true; + true + } + None => false, + } + }) +} diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index 419952b017..b74cbf5dab 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -81,6 +81,10 @@ pub(super) struct Builder<'p> { pub(super) page: Rect, pub(super) glyphs: Option, pub(super) text: Option, + /// The glyphs of the open line of type (`text`) that show, each as the outline it would be + /// kept as: what the line becomes when its type wouldn't draw as the file does + /// ([`super::reshaped`]). + pub(super) text_glyphs: Vec, pub(super) text_as: TextAs, pub(super) actual: super::actual_text::ActualText, pub(super) images: HashMap, diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index b7163c0e58..200ec93673 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -269,6 +269,12 @@ impl TextLine { } } + /// Horizontal type whose characters are all read glyph by glyph from the font's mappings (none + /// from the file's own ActualText). + pub fn mapped(&self) -> bool { + self.upright.is_none() && !self.column && !self.authored.contains(&true) + } + /// The paint the last glyph is filled with. pub fn last_fill(&self) -> Option<&Paint> { self.runs.last()?.0.fill.as_ref() diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 234f4b8a8d..3f74dd9d28 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -80,8 +80,8 @@ pub use file_info::{FileInfo, file_info}; #[cfg(any(test, feature = "read-count"))] pub use import::imports_of; pub use import::{ - INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, - import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, + INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, RESHAPED_TEXT_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, + holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, }; #[cfg(any(test, feature = "read-count"))] pub use pages::reads_of; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 94a20146f2..d63aaedb35 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -44,6 +44,7 @@ mod printadvanced; mod reader_compat; mod reader_streaming; mod realtext; +mod reshaped; mod settings; mod spread; pub(super) mod stroke; diff --git a/crates/pdf/src/tests/reshaped.rs b/crates/pdf/src/tests/reshaped.rs new file mode 100644 index 0000000000..060eca40b7 --- /dev/null +++ b/crates/pdf/src/tests/reshaped.rs @@ -0,0 +1,119 @@ +//! #5235: type in a script whose letters reorder as they are set (here Devanagari) opens as type +//! only when that type draws what the file drew; otherwise the file's glyphs stay, as outlines. +//! The file is written from scratch from the PDF specification (ISO 32000-1 §9.7, composite +//! fonts), its font program a test font built in code over Source Sans 3 +//! ([`vectorcraft_text::test_fonts::matra_font`]): Ka draws Source Sans 3's `a` and the vowel sign +//! I, set before its consonant, draws its `i`. + +use vectorcraft_doc::{Document, NodeKind}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; +use vectorcraft_text::FontDb; +use vectorcraft_text::test_fonts::{MATRA_FAMILY, matra_font}; + +use crate::tests_import_fidelity::stream; +use crate::*; + +const KA: char = 'क'; +const SIGN_I: char = '\u{093F}'; + +/// The test font's glyph of `c` (Source Sans 3's glyph of the Latin letter that draws it) and its +/// advance in thousandths of an em. +fn glyph(c: char) -> (u32, f64) { + let face = FontDb::global().face(MATRA_FAMILY, "Regular").expect("the test font is added"); + let g = face.glyph_for(c); + (g, (face.advance(g) * 1000.0 / face.units_per_em()).round()) +} + +/// A 300 × 200 pt page showing the glyphs of `drawn`, in that order, in the test font at 40 pt +/// from (20, 100): a Type 0 font (Identity-H, two-byte glyph ids) with the font program embedded +/// and a ToUnicode map naming each glyph's character. `sign_width`: the advance the file gives +/// the vowel sign, in thousandths of an em (`None`: the font's). +fn page(drawn: &[char], sign_width: Option) -> Vec { + FontDb::global().add_font(matra_font().expect("the test font builds")); + let program = matra_font().unwrap(); + let hex: String = program.iter().map(|b| format!("{b:02X}")).collect::() + ">"; + let n = first_extra(1); + let rows: Vec = [KA, SIGN_I].iter().map(|&c| format!("<{:04X}> <{:04X}>", glyph(c).0, c as u32)).collect(); + let width = |c| sign_width.filter(|_| c == SIGN_I).unwrap_or(glyph(c).1); + let widths: Vec = [KA, SIGN_I].iter().map(|&c| format!("{} [{}]", glyph(c).0, width(c))).collect(); + let objs = [ + stream(&format!("/Length1 {} /Filter /ASCIIHexDecode", program.len()), &hex), + format!( + "<< /Type /FontDescriptor /FontName /MatratestSans-Regular /Flags 4 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1024 /Descent -400 /CapHeight 660 /StemV 80 /FontFile2 {n} 0 R >>" + ), + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /MatratestSans-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>", + n + 1, + widths.join(" ") + ), + stream( + "", + &format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /Test-UCS def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange {} beginbfchar\n{}\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + rows.len(), + rows.join("\n") + ), + ), + ]; + let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let resources = format!( + "/Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /MatratestSans-Regular /Encoding /Identity-H /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >> >>", + n + 2, + n + 3 + ); + let shown: String = drawn.iter().map(|&c| format!("{:04X}", glyph(c).0)).collect(); + let content = format!("BT /F1 40 Tf 20 100 Td <{shown}> Tj ET"); + pdf_with(&[PdfPage { resources, ..PdfPage::new(300.0, 200.0, &content) }], &refs, None) +} + +fn open(bytes: &[u8]) -> ImportReport { + import_with_report(bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap() +} + +fn texts(d: &Document) -> Vec { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + v.push(t.plain_text()); + } + }); + v +} + +fn outlines(d: &Document) -> usize { + let mut n = 0; + d.walk(|node| n += usize::from(is_glyph_outlines(node))); + n +} + +/// The file draws ककि as it is set: Ka, then the vowel sign before the Ka it follows in reading +/// order. Read glyph by glyph that is Ka, the sign, Ka, which reads किक: set as type, the sign +/// would move in front of the first Ka. The file's three glyphs stay as they are drawn. +#[test] +fn a_syllable_drawn_out_of_reading_order_keeps_the_files_glyphs() { + let report = open(&page(&[KA, SIGN_I, KA], None)); + assert_eq!(texts(&report.document), [""; 0], "no type that draws something else"); + assert_eq!(outlines(&report.document), 1, "the glyphs, as one path"); + assert!(report.warnings.iter().any(|w| w == RESHAPED_TEXT_NOTE), "{:?}", report.warnings); + assert!(report.warnings.iter().all(|w| !is_outlines_note(w) || w == RESHAPED_TEXT_NOTE), "{:?}", report.warnings); +} + +/// Letters the file draws in reading order make type that draws the same: they stay type. +#[test] +fn letters_drawn_in_reading_order_stay_type() { + let report = open(&page(&[KA, KA], None)); + assert_eq!(texts(&report.document), [format!("{KA}{KA}")]); + assert_eq!(outlines(&report.document), 0); + assert!(!report.warnings.iter().any(|w| is_outlines_note(w)), "{:?}", report.warnings); +} + +/// A mark the file gives no advance (as fonts do for the signs they set over a letter) belongs to +/// the line of its letter in a font written horizontally: it is not a glyph of vertical type, set +/// as an object of its own. Here the line it stays on then reads कि, whose sign type would set +/// before the Ka, so the line keeps the file's glyphs, the mark with them. +#[test] +fn a_mark_without_an_advance_stays_with_its_line() { + let report = open(&page(&[KA, SIGN_I], Some(0.0))); + assert_eq!(texts(&report.document), [""; 0], "the mark is no type of its own"); + assert_eq!(outlines(&report.document), 1, "both glyphs, as one path"); +} diff --git a/crates/render/src/pattern/tests.rs b/crates/render/src/pattern/tests.rs index 76aad7fe6f..701afe9b41 100644 --- a/crates/render/src/pattern/tests.rs +++ b/crates/render/src/pattern/tests.rs @@ -251,19 +251,30 @@ fn kept_tiles_stay_within_the_budget() { #[test] fn kept_tiles_count_the_images_they_could_not_decode() { use vectorcraft_doc::{ImageBlob, ImageObject}; - let mut d = doc_with_pattern(TileType::Grid); - // A PNG signature with nothing after it. - d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; - d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; - let mut r = Renderer::new(); - let start = tiles_rasterized(); - for n in 0..3 { - let before = crate::paint::decode_failures(); - frame(&mut r, &d, 1.0); - assert!(crate::paint::decode_failures() > before, "frame {n}"); + // A tile of art with an image is drawn again when the fonts change, and other tests add + // fonts, so the frames come from a run in which the fonts stayed the same. Each run draws a + // new document, whose first frame finds no tile kept from an earlier run. + let _fonts = crate::tests_fontchange::fonts_steady(); + let (failures, rasterized) = crate::tests_fontchange::with_fonts_unchanged(|| { + let mut d = doc_with_pattern(TileType::Grid); + // A PNG signature with nothing after it. + d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); + let im = + ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; + let mut r = Renderer::new(); + let start = tiles_rasterized(); + let failures = [0, 1, 2].map(|_| { + let before = crate::paint::decode_failures(); + frame(&mut r, &d, 1.0); + crate::paint::decode_failures() > before + }); + (failures, tiles_rasterized() - start) + }); + for (n, failed) in failures.into_iter().enumerate() { + assert!(failed, "frame {n}"); } - assert_eq!(tiles_rasterized() - start, 1, "the later frames draw from the tile kept"); + assert_eq!(rasterized, 1, "the later frames draw from the tile kept"); } #[test] diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index bdda3a65a9..79d4d52c11 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -177,11 +177,18 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { .iter() .map(|t| match t.strip_prefix('-') { Some(off) => (off, 0), - None => (t.as_str(), 1), + // `tag=N` picks the font's alternate N: CSS takes the number as the feature's value. + None => { + let on = t.strip_prefix('+').unwrap_or(t); + match on.split_once('=').map(|(tag, n)| (tag, n.parse::())) { + Some((tag, Ok(n))) => (tag, n), + _ => (on, 1), + } + } }) // A feature tag is four letters or digits: anything else could end the CSS string. .filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric())) - .map(|(tag, on)| format!("\"{tag}\" {on}")) + .map(|(tag, value)| format!("\"{tag}\" {value}")) .collect(); // Proportional Metrics: `palt` (SVG writes vertical type as outlines). if st.proportional_metrics { diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index e858f1ba08..4bb4b71d82 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -177,6 +177,19 @@ fn proportional_metrics_write_palt() { assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]); } +/// A feature with a value (`salt=2`) is written as that value, `-tag` as 0, a plain tag as 1; a +/// tag whose value is not a number is left out. +#[test] +fn feature_values_write_their_number() { + let valued = CharStyle { features: vec!["salt=2".into(), "-liga".into(), "ss01".into(), "swsh=12".into(), "salt=x".into()], ..style(12.0) }; + let d = area_doc(vec![run("ab ", style(12.0)), run("cd", valued)], 280.0, Justify::Left); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert_eq!(tags.len(), 2, "{svg}"); + assert!(tags[1].contains("font-feature-settings:"salt" 2, "liga" 0, "ss01" 1, "swsh" 12"), "{}", tags[1]); + assert!(!tags[1].contains("salt=x") && !tags[0].contains("salt"), "{svg}"); +} + /// Character Direction: live text writes each run set Left to Right as a bidi embedding /// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines. #[test] diff --git a/crates/text/src/context_alternates.rs b/crates/text/src/context_alternates.rs new file mode 100644 index 0000000000..034f65f1d5 --- /dev/null +++ b/crates/text/src/context_alternates.rs @@ -0,0 +1,147 @@ +//! The alternate glyphs of one character as it stands in its text: the glyph the shaper makes for +//! it in its word (a medial letter's medial form, not its isolated one), the alternates the font +//! gives that glyph, and for each the OpenType feature value that draws it on that character alone +//! (`salt=2`, `ss03`). Applying that feature to the character's range swaps the glyph in place +//! while the text stays real Unicode and the word is shaped as one piece, so the letters still +//! join. Adobe's Glyphs panel does the same for its Show › Alternates for Current Selection: +//! + +use kurbo::Point; +use vectorcraft_doc::{CharStyle, TextObject}; + +use crate::{FontDb, GlyphAlternate, OtFeatures, edit, layout}; + +/// The most characters on each side of the selected one that are shaped with it: the word it is in. +const MAX_WORD: usize = 256; + +/// The most alternates tried against the text. +const MAX_CANDIDATES: usize = 512; + +/// Does feature `tag` (as a style lists it, with its prefix and value) choose a glyph among a +/// glyph's alternates: `aalt`, `salt`, `swsh` or a stylistic set `ss01` to `ss20`? +pub fn is_alternate_feature(tag: &str) -> bool { + let name = OtFeatures::tag_name(tag); + matches!(name, "aalt" | "salt" | "swsh") || (name.len() == 4 && name.starts_with("ss") && OtFeatures::known_tag(name)) +} + +/// The tags to change a character's features by, as `featureChanges` takes them, so that it has +/// `tag` as its only feature that chooses an alternate: each such feature of `features` turned +/// off, and then `tag` (`salt=2`) when it is given. Empty when the character already has exactly +/// that. +pub fn alternate_changes(features: &[String], tag: Option<&str>) -> Vec { + let mut changes: Vec = vec![]; + for t in features.iter().filter(|t| is_alternate_feature(t) && !t.starts_with('-')) { + let off = format!("-{}", OtFeatures::tag_name(t)); + if tag.is_none_or(|n| OtFeatures::tag_name(n) != OtFeatures::tag_name(t)) && !changes.contains(&off) { + changes.push(off); + } + } + if let Some(tag) = tag { + let now = OtFeatures::from_tags(features.iter().map(String::as_str)); + let wanted = OtFeatures::from_tags([tag]); + let name = OtFeatures::tag_name(tag); + if now.feature_value(name) != wanted.feature_value(name) { + changes.push(tag.to_string()); + } + } + changes +} + +/// One glyph [`alternates_in_context`] lists. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContextAlternate { + pub gid: u32, + /// The feature that lists the glyph among the default glyph's alternates (`aalt` first). + pub feature: [u8; 4], + /// The feature tag that draws the glyph on the character in its word (`salt=2`), when one does. + pub apply: Option, +} + +/// What [`alternates_in_context`] finds. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContextAlternates { + /// The face that supplied the glyph ([`crate::PositionedGlyph::font_id`]). + pub font_id: u32, + /// The glyph the shaper makes for the character with none of its own alternate features: its + /// form in its position. + pub default: u32, + /// The glyph it is drawn with now. + pub current: u32, + /// The alternates of [`Self::default`] in the font's order (`aalt`, `salt`, `ss01`… as + /// [`crate::FontFace::alternates`]), without it unless `aalt` lists it. + pub alternates: Vec, +} + +/// The glyph of the layout `text` that draws the character at `byte`, as (face id, glyph id). +fn glyph_at(db: &FontDb, text: &TextObject, byte: usize) -> Option<(u32, u32, usize)> { + layout(db, text).glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len)).map(|g| (g.font_id, g.gid, g.len)) +} + +/// The alternate glyphs of the character at `byte` of `text` as it stands in its word, from the +/// glyph the shaper makes for it there, and the feature value that draws each on that character +/// alone. The character's own alternate features (`salt`, `ss01`…) are left out when the default +/// glyph is found; each alternate's feature is tried on the character in its word, and the one +/// that draws that glyph there is its `apply` (`salt` before the stylistic sets, `swsh` and +/// `aalt`; a glyph no feature of the font draws in this position has none). `None` when `byte` is +/// not a character of the text or the layout holds no glyph for it. Only the word is shaped (up to +/// 256 characters each side), so the cost does not grow with the text. +pub fn alternates_in_context(db: &FontDb, text: &TextObject, byte: usize) -> Option { + let plain = text.plain_text(); + let ch = plain.get(byte..)?.chars().next()?; + let end = byte + ch.len_utf8(); + let breaks = |c: char| c.is_whitespace() || matches!(c, '\u{2028}' | '\u{2029}'); + let start = plain[..byte].char_indices().rev().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(byte, |(i, _)| i); + let stop = plain[end..].char_indices().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(end, |(i, c)| end + i + c.len_utf8()); + let mut probe = TextObject::point(Point::ZERO, "", CharStyle::default()); + probe.runs = edit::slice_runs(&text.runs, start, stop); + probe.vertical = text.vertical; + probe.para = text.para.clone(); + let (a, b) = (byte - start, end - start); + let (_, current, _) = glyph_at(db, &probe, a)?; + let own = |probe: &TextObject, tag: Option<&str>| { + let mut p = probe.clone(); + edit::style_range(&mut p.runs, a, b, |st| { + st.glyph_alternate = None; + st.features.retain(|t| !is_alternate_feature(t)); + st.features.extend(tag.map(str::to_string)); + }); + glyph_at(db, &p, a) + }; + let (default_font, default, len) = own(&probe, None)?; + let face = db.face_by_id(default_font)?; + // A glyph that more than the character makes (a ligature) has no alternates of its own here. + let subs = if len == ch.len_utf8() { face.substitutions(default) } else { vec![] }; + let mut alternates: Vec = vec![]; + let mut tried: Vec<(String, u32)> = vec![]; + for sub in &subs { + let name = String::from_utf8_lossy(&sub.feature).to_string(); + let values = if sub.glyphs.is_empty() { 0 } else { u16::try_from(sub.glyphs.len()).unwrap_or(u16::MAX) }; + for value in 1..=values { + let tag = if value == 1 { name.clone() } else { format!("{name}={value}") }; + if !is_alternate_feature(&tag) || !OtFeatures::known_tag(&tag) || tried.len() >= MAX_CANDIDATES || tried.iter().any(|(t, _)| *t == tag) { + continue; + } + // A single substitution gives its glyph at any value: only the plain tag is tried. + if sub.kind == crate::SubstitutionKind::Single && value > 1 { + continue; + } + if let Some((font, gid, _)) = own(&probe, Some(&tag)) + && font == default_font + { + tried.push((tag, gid)); + } + } + } + for GlyphAlternate { gid, feature } in crate::fontdb::listed(&subs, default) { + let rank = |tag: &str| match OtFeatures::tag_name(tag) { + "salt" => 0, + "swsh" => 2, + "aalt" => 3, + _ => 1, + }; + let mut drawing: Vec<&(String, u32)> = tried.iter().filter(|(_, g)| *g == gid).collect(); + drawing.sort_by_key(|(t, _)| rank(t)); + alternates.push(ContextAlternate { gid, feature, apply: drawing.first().map(|(t, _)| t.clone()) }); + } + Some(ContextAlternates { font_id: default_font, default, current, alternates }) +} diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index ddf9e5a03c..9f2b555e23 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -43,20 +43,32 @@ pub struct OtFeatures { pub swash: bool, /// The features of [`EXTRA_FEATURES`] turned on, one bit each (off by default): stylistic /// sets, alternates, titling, proportional and lining figures, superscript, subscript, - /// numerators and denominators. + /// numerators and denominators, `aalt`. pub extra: u32, + /// The value of each feature of [`EXTRA_FEATURES`] that is on, as a tag `salt=2` gives it: for + /// a feature with alternates, which of them the font draws (the HarfBuzz feature value, 1 for + /// the first). 0 stands for 1, a plain `salt`. Only read for a feature whose bit of + /// [`Self::extra`] is on; use [`Self::set_extra`] to change a bit and its value together. + pub extra_values: [u16; EXTRA_FEATURES.len()], + /// The value `swsh=N` gives swashes ([`Self::swash`]), as [`Self::extra_values`] for `salt`. + pub swash_value: u16, } +/// The largest value a `tag=N` feature takes: a font's glyph ids are 16 bits, so is an alternate's +/// position in a set. +pub const MAX_FEATURE_VALUE: u16 = u16::MAX; + /// The most entries of a style's feature list read: every segment of its text reads the list again. pub const MAX_STYLE_FEATURES: usize = 256; /// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]: /// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum` -/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr` and -/// denominators `dnom`. -pub const EXTRA_FEATURES: [&str; 28] = [ +/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr`, +/// denominators `dnom` and access all alternates `aalt`. +/// +pub const EXTRA_FEATURES: [&str; 29] = [ "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18", - "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", + "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt", ]; impl Default for OtFeatures { @@ -73,12 +85,53 @@ impl Default for OtFeatures { ordinals: false, swash: false, extra: 0, + extra_values: [0; EXTRA_FEATURES.len()], + swash_value: 0, } } } -fn f(tag: &[u8; 4], on: bool) -> Feature { - Feature::new(Tag::new(tag), on as u32, ..) +/// A feature of `value` over the whole text: 1 and 0 turn it on and off, more picks the font's +/// alternate of that number. +fn f(tag: &[u8; 4], value: impl Into) -> Feature { + Feature::new(Tag::new(tag), value.into(), ..) +} + +/// What a feature tag of a style's list says about one feature ([`parse_tag`]). +struct ParsedTag<'a> { + /// The tag without a `-` or `+` prefix or a `=N` value. + name: &'a str, + /// On (`tag`, `+tag`, `tag=N` for `N` from 1) or off (`-tag`, `tag=0`). + on: bool, + /// The value `tag=N` gives; 1 for a plain tag. + value: u16, +} + +/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] and `swsh` can: +/// each substitutes glyphs, and the value says which alternate. +fn takes_value(name: &str) -> bool { + name == "swsh" || EXTRA_FEATURES.contains(&name) +} + +/// Read a feature tag: `tag`, `+tag`, `-tag` or, for a feature that takes a value, `tag=N` (`N` +/// from 0 to [`MAX_FEATURE_VALUE`] in decimal digits; `-tag=N` is refused). `None` for a tag this +/// set does not understand. +fn parse_tag(tag: &str) -> Option> { + let (off, rest) = match tag.strip_prefix('-') { + Some(r) => (true, r), + None => (false, tag.strip_prefix('+').unwrap_or(tag)), + }; + let (name, value) = match rest.split_once('=') { + None => (rest, None), + Some((name, digits)) => { + if off || digits.is_empty() || digits.len() > 5 || !digits.bytes().all(|b| b.is_ascii_digit()) || !takes_value(name) { + return None; + } + (name, Some(u16::try_from(digits.parse::().ok()?).ok()?)) + } + }; + let known = matches!(name, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&name); + known.then_some(ParsedTag { name, on: !off && value != Some(0), value: value.unwrap_or(1) }) } impl OtFeatures { @@ -104,6 +157,8 @@ impl OtFeatures { ordinals: true, swash: true, extra: u32::MAX >> (32 - EXTRA_FEATURES.len()), + extra_values: [0; EXTRA_FEATURES.len()], + swash_value: 0, }; change(&mut off); change(&mut on); @@ -137,28 +192,68 @@ impl OtFeatures { (self.swash, d.swash, "swsh"), ] { if on != def { - v.push(if on { tag.to_string() } else { format!("-{tag}") }); + v.push(match (on, tag, self.swash_value) { + (true, "swsh", n @ 2..) => format!("{tag}={n}"), + (true, ..) => tag.to_string(), + (false, ..) => format!("-{tag}"), + }); } } - v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(_, t)| t.to_string())); + v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(i, t)| match self.extra_values.get(i) { + None | Some(0 | 1) => t.to_string(), + Some(n) => format!("{t}={n}"), + })); v } - /// Is `tag` (optionally prefixed with `-` or `+`) one this set understands? + /// Is `tag` one this set understands? A feature tag, optionally prefixed with `-` or `+`, or + /// `tag=N` (`N` from 0 to [`MAX_FEATURE_VALUE`], no prefix but `+`) for a feature of + /// [`EXTRA_FEATURES`] or `swsh`, which picks the font's alternate number `N` (`salt=2`; 0 is + /// off, 1 is a plain tag). pub fn known_tag(tag: &str) -> bool { + parse_tag(tag).is_some() + } + + /// The feature a tag of a style's list names, without its `-` or `+` prefix or `=N` value + /// (`salt` for `-salt`, `+salt` and `salt=2`). + pub fn tag_name(tag: &str) -> &str { let t = tag.strip_prefix(['-', '+']).unwrap_or(tag); - matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&t) + t.split_once('=').map_or(t, |(name, _)| name) + } + + /// The value feature `tag` of [`EXTRA_FEATURES`] or `swsh` is on with: `Some(1)` for a plain + /// tag, `Some(N)` for `tag=N`, `None` when it is off or is another tag. + pub fn feature_value(&self, tag: &str) -> Option { + if tag == "swsh" { + return self.swash.then_some(self.swash_value.max(1)); + } + let i = EXTRA_FEATURES.iter().position(|e| *e == tag)?; + (self.extra & (1 << i) != 0).then(|| self.extra_values.get(i).copied().unwrap_or(0).max(1)) + } + + /// Turn the features of [`EXTRA_FEATURES`] in `mask` (bit `i` for the feature `i`) on, with + /// value 1 (a plain tag), or off. + pub fn set_extra(&mut self, mask: u32, on: bool) { + for (i, value) in self.extra_values.iter_mut().enumerate() { + if mask & (1 << i) != 0 { + *value = 0; + } + } + if on { + self.extra |= mask; + } else { + self.extra &= !mask; + } } /// This set with `tags` applied on top. pub fn with_tags<'a>(mut self, tags: impl IntoIterator) -> Self { let o = &mut self; for t in tags { - let (on, t) = match t.strip_prefix('-') { - Some(r) => (false, r), - None => (true, t.strip_prefix('+').unwrap_or(t)), - }; - match t { + let Some(ParsedTag { name, on, value }) = parse_tag(t) else { continue }; + // 1 is a plain tag: only a value from 2 is kept. + let kept = if on && value > 1 { value } else { 0 }; + match name { "liga" => o.ligatures = on, "calt" => o.contextual = on, "dlig" => o.discretionary_ligatures = on, @@ -167,13 +262,15 @@ impl OtFeatures { "onum" => o.oldstyle_figures = on, "tnum" => o.tabular_figures = on, "ordn" => o.ordinals = on, - "swsh" => o.swash = on, + "swsh" => { + o.swash = on; + o.swash_value = kept; + } _ => { - if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == t) { - if on { - o.extra |= 1 << i; - } else { - o.extra &= !(1 << i); + if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == name) { + o.set_extra(1 << i, on); + if let Some(slot) = o.extra_values.get_mut(i) { + *slot = kept; } } } @@ -214,12 +311,12 @@ impl OtFeatures { (s.swash, b"swsh"), ] { if on { - v.push(f(tag, true)); + v.push(f(tag, if tag == b"swsh" { u32::from(s.swash_value.max(1)) } else { 1 })); } } for (i, tag) in EXTRA_FEATURES.iter().enumerate() { if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) { - v.push(f(&t, true)); + v.push(f(&t, u32::from(s.extra_values.get(i).copied().unwrap_or(0).max(1)))); } } if self.vertical { @@ -354,6 +451,69 @@ mod tests { assert_eq!(OtFeatures::from_tags(start.iter().copied()).with_tags(tags.iter().map(String::as_str)), want, "{start:?}"); } } + + /// `tag=N` gives a feature a value: parsed, validated, kept in the style's list as written + /// (1 is a plain tag, 0 is off), replaced by the next value or by `-tag`, and given to the + /// shaper as that value. + #[test] + fn feature_values_are_parsed_validated_and_shaped() { + for t in ["salt=2", "+salt=2", "ss03=12", "aalt=65535", "swsh=3", "salt=0", "salt=01", "titl=2"] { + assert!(OtFeatures::known_tag(t), "{t}"); + } + for t in [ + "salt=", + "salt=-1", + "salt=65536", + "salt=1.5", + "salt=x", + "-salt=2", + "liga=2", + "dlig=1", + "zzzz=2", + "salt==2", + "=2", + "salt=2=3", + "salt=123456", + "salt= 2", + ] { + assert!(!OtFeatures::known_tag(t), "{t}"); + } + assert_eq!(OtFeatures::tag_name("salt=2"), "salt"); + assert_eq!((OtFeatures::tag_name("-ss01"), OtFeatures::tag_name("+salt=3"), OtFeatures::tag_name("liga")), ("ss01", "salt", "liga")); + let o = OtFeatures::from_tags(["salt=2", "ss01", "-liga", "swsh=4"]); + assert_eq!(o.to_tags(), ["-liga", "swsh=4", "ss01", "salt=2"]); + assert_eq!( + (o.feature_value("salt"), o.feature_value("ss01"), o.feature_value("swsh"), o.feature_value("ss02")), + (Some(2), Some(1), Some(4), None) + ); + // The next value replaces the last, `salt=1` is a plain `salt`, `salt=0` and `-salt` are off. + assert_eq!(OtFeatures::from_tags(["salt=2", "salt=3"]).to_tags(), ["salt=3"]); + assert_eq!(OtFeatures::from_tags(["salt=3", "salt"]).to_tags(), ["salt"]); + assert_eq!(OtFeatures::from_tags(["salt=1"]).to_tags(), ["salt"]); + assert_eq!(OtFeatures::from_tags(["salt=2", "-salt"]).to_tags(), Vec::::new()); + assert_eq!(OtFeatures::from_tags(["salt=2", "salt=0"]).to_tags(), Vec::::new()); + assert_eq!(OtFeatures::from_tags(["salt=2", "-salt", "salt"]).to_tags(), ["salt"], "a value does not outlive being turned off"); + // A tag with a value this set does not know is ignored. + assert_eq!(OtFeatures::from_tags(["salt=x", "liga=2"]), OtFeatures::default()); + // The panel's own switches turn a feature on at value 1. + let mut f = OtFeatures::from_tags(["salt=3"]); + f.set_extra(1 << 20, false); + f.set_extra(1 << 20, true); + assert_eq!(f.to_tags(), ["salt"]); + let values = |tags: &[&str]| -> Vec<(String, u32)> { + let st = CharStyle { features: tags.iter().map(|t| t.to_string()).collect(), ..CharStyle::default() }; + OtFeatures::default() + .resolve(&st) + .iter() + .filter(|f| f.value > 0 && f.tag != harfrust::Tag::new(b"kern")) + .map(|f| (f.tag.to_string(), f.value)) + .collect() + }; + assert_eq!(values(&["salt=2"]), [("salt".to_string(), 2)]); + assert_eq!(values(&["salt"]), [("salt".to_string(), 1)]); + assert_eq!(values(&["swsh=3", "ss01"]), [("swsh".to_string(), 3), ("ss01".to_string(), 1)]); + assert!(values(&["-salt", "salt=0"]).is_empty()); + } use crate::{FontDb, layout}; use vectorcraft_doc::TextObject; use vectorcraft_geom::Point; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 12d27bd119..9e18b2a86c 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -25,6 +25,7 @@ mod db; mod dirs; mod face; mod glyph_pen; +mod gsub; mod lookup; mod names; mod scan; @@ -33,6 +34,7 @@ mod traits; mod wanted; pub use alternates::GlyphAlternate; +pub(crate) use alternates::listed; pub use build::style_weight; #[cfg(test)] use cache::GlyphCache; @@ -52,6 +54,7 @@ pub(crate) use face::MAX_LIVE_INSTANCES; pub(crate) use face::PathAlignHeights; pub use face::{FALLBACK_FAMILY, FontFace, IcfMargins}; pub(crate) use glyph_pen::FlipPen; +pub use gsub::{GlyphOrigin, JoiningPosition, Substitution, SubstitutionKind}; #[cfg(not(target_arch = "wasm32"))] use names::SysFallback; #[cfg(test)] diff --git a/crates/text/src/fontdb/alternates.rs b/crates/text/src/fontdb/alternates.rs index 2a582bbe71..4a374755ef 100644 --- a/crates/text/src/fontdb/alternates.rs +++ b/crates/text/src/fontdb/alternates.rs @@ -1,16 +1,16 @@ //! The alternate glyphs a face's `GSUB` table gives one character: the Glyphs panel's Show › //! Alternates for Current Selection. The application finds the character's default glyph in the -//! `cmap` and looks it up in the coverage of each lookup of the features that substitute one glyph -//! for another, as the OpenType registry describes `aalt`. +//! `cmap` and lists what the features that substitute one glyph for another give it, as the +//! OpenType registry describes `aalt` ([`FontFace::substitutions`] reads them). //! //! use skrifa::MetadataProvider; use skrifa::raw::TableProvider; -use skrifa::raw::tables::gsub::{AlternateSubstFormat1, SingleSubst, SubstitutionSubtables}; use vectorcraft_doc::GlyphChoice; use super::FontFace; +use super::gsub::Substitution; /// One glyph [`FontFace::alternates`] lists: its id and the feature it was found in (`aalt`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -19,108 +19,83 @@ pub struct GlyphAlternate { pub feature: [u8; 4], } -/// Caps on what a damaged or very large `GSUB` table can make [`FontFace::alternates`] read: -/// feature records, lookups per feature and in all, subtables per lookup and in all, glyphs per -/// alternate set and glyphs listed in all. -const MAX_FEATURES: usize = 4096; -const MAX_FEATURE_LOOKUPS: usize = 256; -const MAX_LOOKUPS: usize = 4096; -const MAX_SUBTABLES: usize = 256; -const MAX_SUBTABLE_READS: usize = 16384; -const MAX_SET: usize = 512; +/// The most glyphs [`FontFace::alternates`] lists. const MAX_ALTERNATES: usize = 1024; -/// The other discretionary features that substitute one glyph for another (historical forms, -/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in -/// tag order. -const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"]; - -/// Where the glyphs of feature `tag` come in the list: `aalt`, `salt`, `ss01` to `ss20`, `swsh`, -/// `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not listed. -fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> { - let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::().ok()); - match &tag { - b"aalt" => Some((0, 0)), - b"salt" => Some((1, 0)), - [b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)), - b"swsh" => Some((3, 0)), - [b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)), - _ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)), +/// The alternates of glyph `default` that its substitutions `subs` ([`FontFace::substitutions`]) +/// list, in their order, each glyph once with the first feature that gives it: the default glyph +/// itself only when `aalt` gives it. +pub(crate) fn listed(subs: &[Substitution], default: u32) -> Vec { + let mut out: Vec = vec![]; + for substitution in subs { + for &gid in &substitution.glyphs { + let wanted = gid != default || substitution.feature == *b"aalt"; + if wanted && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) { + out.push(GlyphAlternate { gid, feature: substitution.feature }); + } + } } + out } impl FontFace { /// The face's alternate glyphs for `c`, from its `GSUB` table: the glyphs single substitutions /// (lookup type 1) and alternate substitutions (type 3, the whole set in the font's order), in - /// extension lookups (type 7) too, put in place of `c`'s default glyph. `aalt` comes first, - /// then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and - /// `titl`; within a feature its lookups go in lookup order. A glyph found again is left out. - /// The default glyph is listed only when `aalt` lists it. Ligatures and contextual - /// substitutions are not read. Empty when the face does not map `c`, has no `GSUB` table, or - /// the table cannot be read; glyph ids past the face's glyph count are left out. + /// extension lookups (type 7) too, put in place of `c`'s default glyph + /// ([`FontFace::substitutions`]). `aalt` comes first, then `salt`, `ss01` to `ss20`, `swsh`, + /// `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and `titl`; within a feature its lookups go + /// in lookup order. A glyph found again is left out. The default glyph is listed only when + /// `aalt` lists it. Ligatures and contextual substitutions are not read. Empty when the face + /// does not map `c`, has no `GSUB` table, or the table cannot be read; glyph ids past the face's + /// glyph count are left out. pub fn alternates(&self, c: char) -> Vec { - let mut out: Vec = vec![]; - let Some(f) = self.skrifa() else { return out }; - let Some(default) = f.charmap().map(c) else { return out }; - let Ok(gsub) = f.gsub() else { return out }; - let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return out }; - let glyphs = f.maxp().map_or(0, |m| u32::from(m.num_glyphs())); - // The lookups of the listed features, by rank and lookup index. - // The listed feature records are read in rank order, so the cap on lookups in all leaves - // out the lowest ranked ones. - let records = features.feature_records(); - let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records - .iter() - .take(MAX_FEATURES) - .enumerate() - .filter_map(|(i, r)| { - let tag = r.feature_tag().to_be_bytes(); - feature_rank(tag).map(|rank| (rank, i, tag)) + let Some(default) = self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()) else { return vec![] }; + listed(&self.substitutions(default), default) + } + + /// The glyphs no character maps (its `cmap` gives no character for them), by glyph id, without + /// `.notdef`: the positional forms, alternates and ligatures the `GSUB` table reaches and any + /// other glyph of the font. Read once for the face. + pub fn unencoded_glyphs(&self) -> std::sync::Arc<[u32]> { + self.unencoded + .get_or_init(|| { + let Some(font) = self.skrifa() else { return std::sync::Arc::from([]) }; + let mapped: std::collections::HashSet = font.charmap().mappings().map(|(_, g)| g.to_u32()).collect(); + let count = font.maxp().map_or(0, |m| u32::from(m.num_glyphs())); + (1..count).filter(|g| !mapped.contains(g)).collect() }) - .collect(); - ranked.sort_unstable_by_key(|r| (r.0, r.1)); - let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![]; - for (rank, i, tag) in ranked { - let room = MAX_LOOKUPS.saturating_sub(wanted.len()); - if room == 0 { - break; - } - let Some(record) = records.get(i) else { continue }; - let Ok(feature) = record.feature(features.offset_data()) else { continue }; - wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag))); - } - wanted.sort_unstable_by_key(|w| (w.0, w.1)); - wanted.dedup_by_key(|w| (w.0, w.1)); - let mut push = |gid: u32, feature: [u8; 4]| { - let listed = gid != default.to_u32() || feature == *b"aalt"; - if listed && gid < glyphs && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) { - out.push(GlyphAlternate { gid, feature }); - } - }; - let all = lookups.lookups(); - let mut reads = MAX_SUBTABLE_READS; - for (_, index, tag) in wanted { - if reads == 0 { - break; - } - let Ok(subtables) = all.get(usize::from(index)).and_then(|l| l.subtables()) else { continue }; - let n = MAX_SUBTABLES.min(reads); - // A glyph takes the first subtable of a lookup whose coverage holds it. - match subtables { - SubstitutionSubtables::Single(s) => { - if let Some(gid) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| single(&t, default.to_u32())) { - push(gid, tag); - } + .clone() + } + + /// Is glyph `gid` one no character maps and no feature reaches, whose name does not give it + /// to another letter than `c` (`a.salt` for `a` in a font with no `salt`; `A.salt` is not + /// `a`'s)? Such a glyph is not among [`Self::alternates`], yet it can be drawn for `c` as a + /// glyph choice ([`Self::choice`]). + pub fn is_unlisted_alternate(&self, c: char, gid: u32) -> bool { + self.unencoded_glyphs().binary_search(&gid).is_ok() + && self.feature_reaching(gid).is_none() + && self.name_owner(gid).is_none_or(|owner| owner == c) + } + + /// The character a glyph's name says it belongs to: the one whose glyph carries the name up + /// to its first `.` (`A` for `A.salt`, `s` for `s.ss1`; the lowest character when several map + /// that glyph). `None` when the glyph has no name, or no character maps a glyph of that name. + fn name_owner(&self, gid: u32) -> Option { + let base = self.glyph_name(gid)?; + let base = base.split('.').next().unwrap_or_default(); + let names = self.named_chars.get_or_init(|| { + let Some(font) = self.skrifa() else { return Default::default() }; + let mut by_char: Vec<(char, u32)> = font.charmap().mappings().filter_map(|(c, g)| Some((char::from_u32(c)?, g.to_u32()))).collect(); + by_char.sort_unstable(); + let mut out = std::collections::HashMap::new(); + for (c, g) in by_char { + if let Some(name) = self.glyph_name(g) { + out.entry(name).or_insert(c); } - SubstitutionSubtables::Alternate(s) => { - if let Some(set) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| alternate_set(&t, default.to_u32())) { - set.into_iter().for_each(|gid| push(gid, tag)); - } - } - _ => {} } - } - out + out + }); + names.get(base).copied() } /// How many glyphs the face has (its `maxp` table; 0 when it can't be read). @@ -141,25 +116,3 @@ impl FontFace { GlyphChoice { ch: c, gid, font: self.postscript_name(), version: self.version.clone() } } } - -/// The glyph single substitution `t` puts in place of `gid`, when its coverage holds `gid`. -fn single(t: &SingleSubst<'_>, gid: u32) -> Option { - match t { - SingleSubst::Format1(t) => { - t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - // The delta is added modulo 65536. - Some((gid as i32 + i32::from(t.delta_glyph_id())).rem_euclid(0x1_0000) as u32) - } - SingleSubst::Format2(t) => { - let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - t.substitute_glyph_ids().get(usize::from(i)).map(|g| u32::from(g.get().to_u16())) - } - } -} - -/// The alternate set of alternate substitution `t` for `gid`, when its coverage holds `gid`. -fn alternate_set(t: &AlternateSubstFormat1<'_>, gid: u32) -> Option> { - let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - let set = t.alternate_sets().get(usize::from(i)).ok()?; - Some(set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect()) -} diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs index 4946bbd844..afdee14a55 100644 --- a/crates/text/src/fontdb/build.rs +++ b/crates/text/src/fontdb/build.rs @@ -79,6 +79,9 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt colour_bitmaps, colour_layers, svg_glyphs, + gsub: std::sync::OnceLock::new(), + unencoded: std::sync::OnceLock::new(), + named_chars: std::sync::OnceLock::new(), }) } diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index fa59ff8eec..ff9ad8d58f 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -201,6 +201,13 @@ pub struct FontFace { pub(super) colour_layers: bool, /// Has an `SVG ` table: OpenType SVG glyphs ([`FontDb::glyph_colour`]). pub(super) svg_glyphs: bool, + /// What the `GSUB` table substitutes ([`Self::substitutions`]), read the first time it is asked + /// for. + pub(super) gsub: std::sync::OnceLock, + /// [`Self::unencoded_glyphs`], read once. + pub(super) unencoded: std::sync::OnceLock>, + /// [`Self::name_owner`]'s glyph names to characters, read once. + pub(super) named_chars: std::sync::OnceLock>, } /// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance diff --git a/crates/text/src/fontdb/gsub.rs b/crates/text/src/fontdb/gsub.rs new file mode 100644 index 0000000000..888c89ab09 --- /dev/null +++ b/crates/text/src/fontdb/gsub.rs @@ -0,0 +1,359 @@ +//! What a face's `GSUB` table substitutes for a glyph: the alternates of one glyph (the Glyphs +//! panel's Show › Alternates for Current Selection), the feature that gives a glyph no character +//! maps to (Show › Entire Font) and glyph names. Read once for a face, the first time one is asked +//! for, and kept with it ([`FontFace`]). +//! +//! A feature of alternates (an alternate substitution, lookup type 3; `salt`, `aalt`, `swsh`) +//! takes the alternate its value says: with the feature at value `N`, a glyph in its coverage is +//! replaced by the `N`th glyph of its set (the first at value 1, none at 0 or past the set), which +//! is how the shaper reads it. A feature of single substitutions (type 1; `ss01`, `smcp`) replaces +//! the glyph at any value from 1. +//! +//! + +use std::collections::{HashMap, HashSet}; + +use skrifa::MetadataProvider; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::gsub::{AlternateSubstFormat1, LigatureSubstFormat1, MultipleSubstFormat1, SingleSubst, SubstitutionSubtables}; + +use super::FontFace; + +/// Caps on what a damaged or very large `GSUB` table can make a face read: feature records, +/// lookups per feature and in all, subtables per lookup and in all, glyphs per alternate set, and +/// glyphs and substitutions listed in all. +pub(super) const MAX_FEATURES: usize = 4096; +const MAX_FEATURE_LOOKUPS: usize = 256; +const MAX_LOOKUPS: usize = 4096; +const MAX_SUBTABLES: usize = 256; +const MAX_SUBTABLE_READS: usize = 16384; +pub(super) const MAX_SET: usize = 512; +const MAX_ENTRIES: usize = 1 << 18; + +/// The other discretionary features that substitute one glyph for another (historical forms, +/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in +/// tag order. +const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"]; + +/// Where the substitutions of feature `tag` come in a glyph's list: `aalt`, `salt`, `ss01` to +/// `ss20`, `swsh`, `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not +/// listed. +fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> { + let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::().ok()); + match &tag { + b"aalt" => Some((0, 0)), + b"salt" => Some((1, 0)), + [b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)), + b"swsh" => Some((3, 0)), + [b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)), + _ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)), + } +} + +/// The rank of the features that give no listed alternates but reach glyphs ([`FontFace::feature_reaching`]). +const UNLISTED: (u8, u8) = (9, 0); + +/// How a feature gives its glyphs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SubstitutionKind { + /// One glyph, whatever the feature's value is from 1 (a single substitution, type 1). + Single, + /// A set of glyphs, the one at the feature's value (an alternate substitution, type 3). + Alternate, +} + +/// What one feature substitutes for a glyph ([`FontFace::substitutions`]). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Substitution { + /// The feature's tag (`salt`). + pub feature: [u8; 4], + pub kind: SubstitutionKind, + /// The glyphs, in the font's order: the one glyph of a single substitution, or the alternate + /// set (the feature at value `N` gives the `N`th). + pub glyphs: Vec, +} + +impl Substitution { + /// The value the feature takes to give `glyph` (`salt=2` for 2), `None` when it never gives it. + pub fn value_for(&self, glyph: u32) -> Option { + match self.kind { + SubstitutionKind::Single => (self.glyphs.first() == Some(&glyph)).then_some(1), + SubstitutionKind::Alternate => self.glyphs.iter().position(|g| *g == glyph).and_then(|i| u16::try_from(i + 1).ok()), + } + } +} + +type Tag = [u8; 4]; + +/// A face's substitutions, by the glyph they replace and by the glyph they give. +#[derive(Default)] +pub(super) struct GsubIndex { + /// The listed features' ([`feature_rank`]) substitutions of each glyph, by feature rank and + /// lookup. + by_input: HashMap>, + /// The first feature, listed ones first, that gives each glyph: by a single, multiple, + /// alternate or ligature substitution, and the glyph it replaces (not known for a ligature). + reached: HashMap)>, +} + +/// The glyph single substitution `t` puts in place of each glyph its coverage holds. +fn single_pairs(t: &SingleSubst<'_>, room: usize) -> Vec<(u32, u32)> { + match t { + SingleSubst::Format1(t) => { + let Ok(coverage) = t.coverage() else { return vec![] }; + // The delta is added modulo 65536. + let delta = i32::from(t.delta_glyph_id()); + coverage.iter().take(room).map(|g| (g.to_u32(), (g.to_u32() as i32 + delta).rem_euclid(0x1_0000) as u32)).collect() + } + SingleSubst::Format2(t) => { + let Ok(coverage) = t.coverage() else { return vec![] }; + let out = t.substitute_glyph_ids(); + coverage.iter().zip(out).take(room).map(|(g, to)| (g.to_u32(), u32::from(to.get().to_u16()))).collect() + } + } +} + +/// The alternate set of each glyph alternate substitution `t`'s coverage holds. +fn alternate_sets(t: &AlternateSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec)> { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sets = t.alternate_sets(); + coverage + .iter() + .enumerate() + .take(room) + .filter_map(|(i, g)| { + let set = sets.get(i).ok()?; + Some((g.to_u32(), set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect())) + }) + .collect() +} + +/// The glyphs multiple substitution `t` puts in place of its covered glyphs, and the ligature +/// glyphs of ligature substitution `l`, as (glyph, glyphs it becomes). +fn multiple_pairs(t: &MultipleSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec)> { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sequences = t.sequences(); + coverage + .iter() + .enumerate() + .take(room) + .filter_map(|(i, g)| { + let sequence = sequences.get(i).ok()?; + Some((g.to_u32(), sequence.substitute_glyph_ids().iter().map(|g| u32::from(g.get().to_u16())).collect())) + }) + .collect() +} + +fn ligature_glyphs(t: &LigatureSubstFormat1<'_>, room: usize) -> Vec { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sets = t.ligature_sets(); + let mut out = vec![]; + for (i, _) in coverage.iter().enumerate().take(room) { + let Ok(set) = sets.get(i) else { continue }; + out.extend(set.ligatures().iter().flatten().take(room).map(|l| u32::from(l.ligature_glyph().to_u16()))); + if out.len() >= room { + break; + } + } + out +} + +impl GsubIndex { + /// Read `face`'s `GSUB` table. Empty when the face has none or it cannot be read; glyph ids past + /// the face's glyph count are left out; the caps above bound what a damaged table makes it read. + fn read(face: &FontFace) -> Self { + let mut index = Self::default(); + let Some(font) = face.skrifa() else { return index }; + let Ok(gsub) = font.gsub() else { return index }; + let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return index }; + let glyphs = font.maxp().map_or(0, |m| u32::from(m.num_glyphs())); + // The lookups of the features, by rank and lookup index. The feature records are read in + // rank order, so the cap on lookups in all leaves out the lowest ranked ones. + let records = features.feature_records(); + let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records + .iter() + .take(MAX_FEATURES) + .enumerate() + .map(|(i, r)| { + let tag = r.feature_tag().to_be_bytes(); + (feature_rank(tag).unwrap_or(UNLISTED), i, tag) + }) + .collect(); + ranked.sort_unstable_by_key(|r| (r.0, r.1)); + let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![]; + for (rank, i, tag) in ranked { + let room = MAX_LOOKUPS.saturating_sub(wanted.len()); + if room == 0 { + break; + } + let Some(record) = records.get(i) else { continue }; + let Ok(feature) = record.feature(features.offset_data()) else { continue }; + wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag))); + } + wanted.sort_by_key(|w| (w.0, w.1)); + wanted.dedup_by_key(|w| (w.0, w.1)); + let all = lookups.lookups(); + let mut reads = MAX_SUBTABLE_READS; + let mut entries = 0_usize; + for (rank, lookup, tag) in wanted { + if reads == 0 || entries >= MAX_ENTRIES { + break; + } + let Ok(subtables) = all.get(usize::from(lookup)).and_then(|l| l.subtables()) else { continue }; + let n = MAX_SUBTABLES.min(reads); + let listed = rank != UNLISTED; + // A glyph takes the first subtable of a lookup whose coverage holds it. + let mut seen: HashSet = HashSet::new(); + let mut note = |index: &mut Self, input: u32, kind: SubstitutionKind, out: Vec| { + if !seen.insert(input) { + return; + } + let out: Vec = out.into_iter().filter(|g| *g < glyphs).collect(); + for g in &out { + if *g != input && index.reached.len() < MAX_ENTRIES { + index.reached.entry(*g).or_insert((tag, Some(input))); + } + } + if listed && !out.is_empty() && entries < MAX_ENTRIES { + entries += 1; + index.by_input.entry(input).or_default().push(Substitution { feature: tag, kind, glyphs: out }); + } + }; + match subtables { + SubstitutionSubtables::Single(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, to) in single_pairs(&t, MAX_ENTRIES) { + note(&mut index, input, SubstitutionKind::Single, vec![to]); + } + } + } + SubstitutionSubtables::Alternate(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, set) in alternate_sets(&t, MAX_ENTRIES) { + note(&mut index, input, SubstitutionKind::Alternate, set); + } + } + } + SubstitutionSubtables::Multiple(s) => { + let mut covered: HashSet = HashSet::new(); + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, to) in multiple_pairs(&t, MAX_ENTRIES) { + // Not listed as alternates, only reached. + if covered.insert(input) { + for g in to.into_iter().filter(|g| *g < glyphs && *g != input) { + if index.reached.len() < MAX_ENTRIES { + index.reached.entry(g).or_insert((tag, Some(input))); + } + } + } + } + } + } + SubstitutionSubtables::Ligature(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for g in ligature_glyphs(&t, MAX_ENTRIES).into_iter().filter(|g| *g < glyphs) { + if index.reached.len() < MAX_ENTRIES { + index.reached.entry(g).or_insert((tag, None)); + } + } + } + } + _ => {} + } + } + index + } +} + +/// The most substitutions [`FontFace::glyph_origin`] follows back. +const MAX_ORIGIN_STEPS: usize = 8; + +/// The character a glyph comes from ([`FontFace::glyph_origin`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GlyphOrigin { + pub ch: char, + /// The features from the glyph back to the character's glyph, the glyph's own first. + pub features: Vec<[u8; 4]>, +} + +impl GlyphOrigin { + /// The place in a word that the glyph's features give it: the first of `init`, `medi`, `fina` + /// and `isol` among them, `None` when none is (a letter that does not join). + pub fn position(&self) -> Option { + self.features.iter().find_map(|f| match f { + b"init" => Some(crate::JoiningPosition::Initial), + b"medi" => Some(crate::JoiningPosition::Medial), + b"fina" => Some(crate::JoiningPosition::Final), + b"isol" => Some(crate::JoiningPosition::Isolated), + _ => None, + }) + } +} + +/// Where a letter stands in a word, as the `init`, `medi`, `fina` and `isol` features say. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JoiningPosition { + Initial, + Medial, + Final, + Isolated, +} + +impl FontFace { + fn gsub_index(&self) -> &GsubIndex { + self.gsub.get_or_init(|| GsubIndex::read(self)) + } + + /// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, + /// `jalt`, `nalt`, `ornm` and `titl` of the face's `GSUB` table substitute for glyph `gid`, in + /// that order of features and within a feature in lookup order: the glyph of each single + /// substitution (lookup type 1) and the whole set of each alternate substitution (type 3), in + /// extension lookups (type 7) too, as the shaper reads them. A feature gives the glyph its + /// first substitution (the first subtable of the lookup whose coverage holds it). Ligatures and + /// contextual substitutions are not read, nor is the choice of a variable font's instance of + /// `FeatureVariations`. Empty when the face has no `GSUB` table or it cannot be read; glyph ids + /// past the face's glyph count are left out. + pub fn substitutions(&self, gid: u32) -> Vec { + self.gsub_index().by_input.get(&gid).cloned().unwrap_or_default() + } + + /// The feature of the `GSUB` table that gives glyph `gid` as a substitute (by a single, + /// multiple, alternate or ligature substitution), a listed feature ([`Self::substitutions`]) + /// before another (`init`, `liga`), if any does. Not read through a contextual lookup. + pub fn feature_reaching(&self, gid: u32) -> Option<[u8; 4]> { + self.gsub_index().reached.get(&gid).map(|r| r.0) + } + + /// Where glyph `gid` comes from: the character whose glyph the `GSUB` table's substitutions + /// lead to it from, and the features on the way, `gid`'s own first (`aalt`, then `fina` for + /// `yehfarsi.fina.alt2`, whose character is U+06CC). The chain follows the first substitution + /// that gives each glyph back to a glyph none gives, as far as [`MAX_ORIGIN_STEPS`] steps; a + /// presentation-form character that maps a glyph in the chain does not stop it, and the + /// character is the lowest one outside the Arabic presentation forms that maps the glyph it + /// ends on (the lowest of all when only presentation forms do). `None` when no character maps + /// that glyph (a glyph no feature reaches, a ligature). + pub fn glyph_origin(&self, gid: u32) -> Option { + let index = self.gsub_index(); + let mut features = vec![]; + let mut at = gid; + for _ in 0..MAX_ORIGIN_STEPS { + let Some((tag, from)) = index.reached.get(&at) else { break }; + features.push(*tag); + at = (*from)?; + } + let font = self.skrifa()?; + let presentation = |c: u32| (0xFB50..=0xFDFF).contains(&c) || (0xFE70..=0xFEFF).contains(&c); + let mut chars: Vec = font.charmap().mappings().filter(|(_, g)| g.to_u32() == at).map(|(c, _)| c).collect(); + chars.sort_by_key(|c| (presentation(*c), *c)); + let ch = chars.first().copied().and_then(char::from_u32)?; + Some(GlyphOrigin { ch, features }) + } + + /// The name of glyph `gid` from the font's `post` or `CFF` table: `None` for a glyph past the + /// face's glyphs, or when the font has no names (the ids are then `gid12`). + pub fn glyph_name(&self, gid: u32) -> Option { + let font = self.skrifa()?; + let name = font.glyph_names().get(skrifa::GlyphId::new(gid))?; + (!name.is_synthesized()).then(|| name.as_str().to_string()) + } +} diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index 435bbc4ed7..b181672569 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -37,10 +37,11 @@ pub(crate) fn is_harakat(c: char) -> bool { } /// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the -/// stroke is drawn, overprint, and the character style name? Those are not read by the shaper -/// and change no glyph, advance or position. Nor is the space before a line's first character -/// inside a cluster, which takes it from its first character (#5111), so it is ignored too. Stricter than [`crate::shape`]'s `shapes_alike` -/// (which also ignores baseline shift, tracking, underline, …). +/// stroke is drawn, overprint, and the character style name? OpenType feature differences are +/// ignored here: they already apply to their own character ranges during shaping. Nor is the +/// space before a line's first character read inside a cluster, which takes it from its first +/// character (#5111). Stricter than [`crate::shape`]'s `shapes_alike` (which also ignores baseline +/// shift, tracking, underline, …). pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { // Exhaustive: a new attribute must explicitly keep or relax this boundary rule. let CharStyle { @@ -61,7 +62,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { underline, strikethrough, all_caps, - features, + features: _, position, small_caps, char_align, @@ -110,7 +111,6 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { && underline == &b.underline && strikethrough == &b.strikethrough && all_caps == &b.all_caps - && features == &b.features && position == &b.position && small_caps == &b.small_caps && char_align == &b.char_align @@ -129,7 +129,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { } /// Is the run boundary at byte `at` of `text`, between styles `before` and `after`, one that -/// stays inside a shaping cluster: they differ only in paint and a harakat is on either side? +/// stays inside a shaping cluster: only paint or features differ and a harakat is on either side? pub(crate) fn paint_only_mark_boundary(text: &str, at: usize, before: &CharStyle, after: &CharStyle) -> bool { let next = text.get(at..).and_then(|s| s.chars().next()); let previous = text.get(..at).and_then(|s| s.chars().next_back()); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d2e3cf1dc8..96b51e7b4c 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -13,6 +13,7 @@ mod bitmap; mod colr; mod composer; +mod context_alternates; pub mod craft_fonts; pub mod edit; pub mod embed; @@ -34,12 +35,16 @@ pub mod test_fonts; pub mod thread; pub use bitmap::GlyphImage; +pub use context_alternates::{ContextAlternate, ContextAlternates, alternate_changes, alternates_in_context, is_alternate_feature}; pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; -pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; +pub use features::{ + EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_FEATURE_VALUE, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by, +}; pub use fontdb::{ - CompositeCoverage, FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, LentCompositeFonts, - PlatformFontFiles, StylisticSet, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, - set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs, + CompositeCoverage, FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, GlyphOrigin, IcfMargins, JoiningPosition, + LentCompositeFonts, PlatformFontFiles, StylisticSet, Substitution, SubstitutionKind, WantedFont, WantedFonts, app_font_dir, is_font_file, + is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, + user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; pub use layout::{envelope_ink, register_envelope_ink}; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index ba7339bc6b..c1e2b9e0f5 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -875,33 +875,113 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: source.get(i).filter(|(range, _)| range.contains(&byte)).map(|_| span.start + i) } -/// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that -/// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the -/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. The space -/// before a line's first character ([`space_before`]) is never read inside a cluster, which takes it -/// from its first character, so it doesn't split a ligature either. +/// Can one glyph draw characters in styles `a` and `b`? OpenType features already apply to their +/// own character ranges during shaping, so feature-only differences do not split a cluster. +/// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets. +/// The space before a line's first character ([`space_before`]) comes from the cluster's first +/// character, so it does not split a ligature either. fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { - let b = &CharStyle { kerning_before: a.kerning_before, ..b.clone() }; - if a == b { - return true; - } - !vertical - && *a - == CharStyle { - standard_vertical_roman: a.standard_vertical_roman, - tcy_up_down: a.tcy_up_down, - tcy_left_right: a.tcy_left_right, - ..b.clone() - } + // Exhaustive, like `shapes_alike`: compare borrowed fields without copying feature lists. + let CharStyle { + font_family, + font_style, + font_version, + font_axes, + size, + leading, + tracking, + kerning, + kerning_before, + optical_kerning, + baseline_shift, + h_scale, + v_scale, + rotation, + fill, + stroke, + stroke_width, + underline, + strikethrough, + all_caps, + features: _, + style_name, + overprint_fill, + overprint_stroke, + stroke_cap, + stroke_join, + stroke_miter_limit, + stroke_dash, + stroke_align, + position, + small_caps, + char_align, + char_direction, + kashidas, + proportional_metrics, + tate_chu_yoko, + tcy_up_down, + tcy_left_right, + standard_vertical_roman, + digits, + font_height_reference, + no_break, + language, + glyph_alternate, + } = a; + // Keep PartialEq's non-reflexive behavior for ignored numeric fields, without cloning styles. + let offsets = (standard_vertical_roman, tcy_up_down, tcy_left_right); + let other_offsets = if vertical { (&b.standard_vertical_roman, &b.tcy_up_down, &b.tcy_left_right) } else { offsets }; + kerning_before == &a.kerning_before + && offsets == other_offsets + && font_family == &b.font_family + && font_style == &b.font_style + && font_version == &b.font_version + && font_axes == &b.font_axes + && size == &b.size + && leading == &b.leading + && tracking == &b.tracking + && kerning == &b.kerning + && optical_kerning == &b.optical_kerning + && baseline_shift == &b.baseline_shift + && h_scale == &b.h_scale + && v_scale == &b.v_scale + && rotation == &b.rotation + && fill == &b.fill + && stroke == &b.stroke + && stroke_width == &b.stroke_width + && underline == &b.underline + && strikethrough == &b.strikethrough + && all_caps == &b.all_caps + && style_name == &b.style_name + && overprint_fill == &b.overprint_fill + && overprint_stroke == &b.overprint_stroke + && stroke_cap == &b.stroke_cap + && stroke_join == &b.stroke_join + && stroke_miter_limit == &b.stroke_miter_limit + && stroke_dash == &b.stroke_dash + && stroke_align == &b.stroke_align + && position == &b.position + && small_caps == &b.small_caps + && char_align == &b.char_align + && char_direction == &b.char_direction + && kashidas == &b.kashidas + && proportional_metrics == &b.proportional_metrics + && tate_chu_yoko == &b.tate_chu_yoko + && digits == &b.digits + && font_height_reference == &b.font_height_reference + && no_break == &b.no_break + && language == &b.language + && glyph_alternate == &b.glyph_alternate } /// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text? /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the /// character style's name, leading, baseline shift, rotation, Character Alignment, Character /// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its -/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]) -/// and the tracking, as long as the tracking keeps or drops standard -/// ligatures alike ([`crate::features::ligatures_suppressed_by`]). +/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]), +/// the OpenType features (each run's apply to its own characters, as features over a range of the +/// one buffer) and the tracking, as long as the tracking keeps or drops standard ligatures alike +/// ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { // Every field is named, so that a new attribute has to be sorted into one group or the other. let CharStyle { @@ -925,7 +1005,9 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { underline: _, strikethrough: _, all_caps, - features, + // Runs with different feature lists shape as one buffer: each run's features apply to its + // own characters only ([`shape_segment`]). + features: _, style_name: _, overprint_fill: _, overprint_stroke: _, @@ -958,7 +1040,6 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { && *h_scale == b.h_scale && *v_scale == b.v_scale && *all_caps == b.all_caps - && *features == b.features && *position == b.position && *small_caps == b.small_caps && *proportional_metrics == b.proportional_metrics @@ -1121,6 +1202,23 @@ struct Shaped { chars: Vec, } +/// The features that turn the whole-text features `base` into `own` over the clusters `range`, put +/// after `base` so that they win there: each feature of `own` that `base` lacks or has with +/// another value, and each of `base` that `own` leaves out, back at the value the shaper has +/// without it (on for the default features, off for the rest). The range is given with an +/// inclusive end because harfrust's `Feature::new` takes an exclusive end one cluster short, but +/// applies it as exclusive: a one-byte character would get an empty range. +fn ranged_features(base: &[Feature], own: &[Feature], range: Range, out: &mut Vec) { + const ON_BY_DEFAULT: [&[u8; 4]; 7] = [b"kern", b"liga", b"clig", b"calt", b"apkn", b"vkrn", b"vapk"]; + for f in own.iter().filter(|f| !base.iter().any(|b| b.tag == f.tag && b.value == f.value)) { + out.push(Feature::new(f.tag, f.value, range.start..=range.end)); + } + for b in base.iter().filter(|b| !own.iter().any(|f| f.tag == b.tag)) { + let default = ON_BY_DEFAULT.iter().any(|t| Tag::new(t) == b.tag); + out.push(Feature::new(b.tag, u32::from(default), range.start..=range.end)); + } +} + fn is_mark(c: char) -> bool { use unicode_general_category::{GeneralCategory, get_general_category}; matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark) @@ -1132,8 +1230,8 @@ fn is_mark(c: char) -> bool { /// characters with automatic kerning: the manual value replaces the font's pair after its /// character, and a character before it with automatic kerning keeps the font's pair with it /// (except before a character that All Caps or Small Caps sets as several letters, such as ß as -/// SS). A cluster that would take in characters of runs with different styles (a ligature, a -/// base with its marks) is not formed: the segment is shaped in pieces at those run boundaries. +/// SS). A cluster that would take in characters of runs with different drawing attributes (a +/// ligature, a base with its marks) is not formed, except for paint-only harakat differences. fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFeatures, out: &mut Vec) { let Segment { range, runs, source_runs, st, face, small, level } = seg; let (range, small) = (range.clone(), *small); @@ -1196,6 +1294,23 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let styled_marks = !mark_boundaries.is_empty(); let char_level = std::cell::Cell::new(false); + // Runs that differ only in their OpenType feature lists are shaped as one buffer: the features + // of each run apply to its own characters only (a feature with a range of clusters, which here + // are source bytes), over those of `base_style`, whose features apply to the whole segment. So + // a feature or an alternate (`salt=2`) on one letter of an Arabic word leaves the joining, + // ligatures and kerning of the whole word to the shaper, as the same text without it. + // `variant` makes a style's features and `base` is what it makes for `base_style`. + let run_features = |variant: &dyn Fn(&CharStyle) -> Vec, base_style: &CharStyle, base: &[Feature]| { + let mut ranged = Vec::new(); + for (r, run_style) in runs.get(source_runs.clone()).unwrap_or_default() { + let (a, b) = (r.start.max(range.start), r.end.min(range.end)); + if run_style.features != base_style.features && a < b { + let own = variant(&CharStyle { features: run_style.features.clone(), ..base_style.clone() }); + ranged_features(base, &own, a..b, &mut ranged); + } + } + ranged + }; // Shape the segment as if all of it were in `st`, with the features `more` on top. let shape_in = |st: &CharStyle, more: &[Feature]| { // A BCP 47 tag is short ASCII; anything else (a file may hold anything) is shaped without one. @@ -1210,8 +1325,13 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); // The baseline and character-level pass use exactly the same resolved features. // Alternate positioning passes still resolve their own features, with `more` last. - let with_more = |resolved: Vec| resolved.into_iter().chain(more.iter().cloned()).collect::>(); - let along_features = with_more(feats.resolve(st)); + let with_more = |variant: &dyn Fn(&CharStyle) -> Vec| { + let mut resolved = variant(st); + resolved.extend(run_features(variant, st, &resolved)); + resolved.extend(more.iter().cloned()); + resolved + }; + let along_features = with_more(&|s| feats.resolve(s)); // gid, cluster, x and y advance, x and y offset. let shape = |feats: &[Feature], direction: Direction| { let mut buf = UnicodeBuffer::new(); @@ -1274,7 +1394,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe if feats.proportional(st) { // `palt` is positioning only: the same glyphs set on their full widths tell which // advances it changed (kerning and the rest change both alike). - let full = shape(&with_more(feats.resolve_fixed_width(st)), along); + let full = shape(&with_more(&|s| feats.resolve_fixed_width(s)), along); if same_glyphs(&full) { *proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect(); } @@ -1285,7 +1405,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe // and without either tell how it moves each and changes its advance down the column // (y up, advances negative). Which advances `vpal` itself changed comes from setting // them with it alone. - let ttb = |proportional, kerning| shape(&with_more(feats.resolve_vertical(st, proportional, kerning)), Direction::TopToBottom); + let ttb = |proportional, kerning| shape(&with_more(&|s| feats.resolve_vertical(s, proportional, kerning)), Direction::TopToBottom); let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false)); if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) { *vpal = with @@ -1385,8 +1505,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe cluster_starts.dedup(); // A glyph is drawn in one style: where a cluster would take in characters of runs in different // styles, the runs are shaped apart, as runs that do not shape alike are. - // Except at harakat styled apart only in paint ([`crate::harakat`]), and, in horizontal type, where - // the runs differ only in settings that vertical type alone reads. + // Except at harakat styled apart only in paint ([`crate::harakat`]), feature-only boundaries, + // and, in horizontal type, settings that vertical type alone reads. let inside_clusters: Vec = boundaries() .filter(|&b| cluster_starts.binary_search(&b).is_err() && !drawn_alike(run_at(b - 1).1, run_at(b).1, feats.vertical)) .filter(|b| mark_boundaries.binary_search(b).is_err()) diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 1300920ad1..2d766ae571 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -128,6 +128,41 @@ pub fn variable_font() -> Option> { Some(builder.build()) } +/// The family of [`unlisted_variable_font`]. +pub const UNLISTED_VARIABLE_FAMILY: &str = "Unlisted Varitest Sans"; + +/// A variable face with no GSUB: `a.a` is an unencoded alternate named for `a`, but the Glyphs +/// panel has no listed alternates. The weight axis comes from [`variable_font`]. +pub fn unlisted_variable_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + + let bytes = variable_font()?; + let font = skrifa::FontRef::new(&bytes).ok()?; + let mappings = [' ', 'a', 'l'] + .into_iter() + .map(|c| Some((c, write_fonts::types::GlyphId::new(font.charmap().map(c)?.to_u32())))) + .collect::>>()?; + let cmap = Cmap::from_mappings(mappings).ok()?; + let name = name_table(&[ + (1, UNLISTED_VARIABLE_FAMILY), + (2, "Regular"), + (4, "Unlisted Varitest Sans Regular"), + (5, "Version 1.000"), + (6, "UnlistedVaritestSans-Regular"), + (256, "Weight"), + ])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_table(&cmap).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if tag != Tag::new(b"GSUB") && !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`two_axis_font`]. pub const TWO_AXIS_FAMILY: &str = "Biaxtest Sans"; @@ -791,6 +826,211 @@ pub fn alternates_font(aalt: bool) -> Option> { Some(builder.build()) } +/// The family of [`capped_candidates_font`]. +pub const CAPPED_CANDIDATES_FAMILY: &str = "Captest Sans"; + +/// The bundled Source Sans 3 Regular, its `cmap` kept, with a `GSUB` table for `a` of an `aalt` +/// alternate substitution that lists `b` 512 times (the longest set read: 512 values to try) and a +/// `salt` single substitution that gives `c`. Family [`CAPPED_CANDIDATES_FAMILY`]. `None` when it +/// can't be built. +pub fn capped_candidates_font() -> Option> { + use write_fonts::tables::gsub::{ + AlternateSet, AlternateSubstFormat1, Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList, + }; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let name = + name_table(&[(1, CAPPED_CANDIDATES_FAMILY), (2, "Regular"), (4, "Captest Sans Regular"), (5, "Version 1.000"), (6, "CaptestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let a = gid('a')?; + let cover = || CoverageFormat1::new(vec![a]); + let alternates = AlternateSubstFormat1::new(cover().into(), vec![AlternateSet::new(vec![gid('b')?; 512])]); + let salt = SingleSubst::from(SingleSubstFormat2::new(cover().into(), vec![gid('c')?])); + let lookups = SubstitutionLookupList::new(vec![ + SubstitutionLookup::Alternate(Lookup::new(Default::default(), vec![alternates])), + SubstitutionLookup::Single(Lookup::new(Default::default(), vec![salt])), + ]); + let records = + vec![FeatureRecord::new(Tag::new(b"aalt"), Feature::new(None, vec![0])), FeatureRecord::new(Tag::new(b"salt"), Feature::new(None, vec![1]))]; + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0, 1])), vec![]))]); + builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// The family of [`joining_alternates_font`]. +pub const JOINING_ALTERNATES_FAMILY: &str = "Jointest Sans"; + +/// Beh, teh, noon and alef: the letters [`joining_alternates_font`] maps. +pub const JOINING_LETTERS: [char; 4] = ['\u{0628}', '\u{062A}', '\u{0646}', '\u{0627}']; + +/// A test-only Arabic font over the bundled Source Sans 3, whose glyphs stand for the positional +/// forms of beh, teh, noon and alef ([`JOINING_LETTERS`]), each a Latin letter's outline: isolated +/// b t n I, initial B T N, medial v u m, final V U M A (alef joins to the right only). Its `arab` +/// `GSUB` has `init`, `medi` and `fina` single substitutions from the isolated glyphs, `salt` +/// alternate substitutions of the medial teh u (w, x, y: three alternates), of the final teh U +/// (W, X), of the isolated teh t (E) and of the medial beh v (z), and `ss01` single substitutions +/// u to w, U to W, t to E and v to z. A set differs by position, as in a font that draws each +/// letter's alternates for the position they join in. `cv01` gives the medial noon m the glyph d, +/// an alternate that has no style tag. Family [`JOINING_ALTERNATES_FAMILY`]. `None` when it can't +/// be built. +pub fn joining_alternates_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + use write_fonts::tables::gsub::{ + AlternateSet, AlternateSubstFormat1, Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList, + }; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + let font = skrifa::FontRef::new(SOURCE_SANS).ok()?; + let charmap = font.charmap(); + let gid = |c: char| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let family = JOINING_ALTERNATES_FAMILY; + let full = format!("{family} Regular"); + let postscript = format!("{}-Regular", family.replace(' ', "")); + let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (5, "Version 1.000"), (6, &postscript)])?; + let mapping = [(JOINING_LETTERS[0], 'b'), (JOINING_LETTERS[1], 't'), (JOINING_LETTERS[2], 'n'), (JOINING_LETTERS[3], 'I'), (' ', ' ')]; + let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); + let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + // A single substitution by (glyph, replacement) pairs, sorted by glyph id as coverage needs. + let single = |pairs: &[(char, char)]| { + let mut ids = pairs.iter().map(|(from, to)| Some((gid(*from)?, gid(*to)?))).collect::>>()?; + ids.sort_by_key(|p| p.0.to_u16()); + let from = ids.iter().map(|p| p.0).collect(); + let to = ids.iter().map(|p| p.1).collect(); + Some(SubstitutionLookup::Single(Lookup::new( + Default::default(), + vec![SingleSubst::from(SingleSubstFormat2::new(CoverageFormat1::new(from).into(), to))], + ))) + }; + let alternates = |sets: &[(char, &[char])]| { + let mut ids = sets + .iter() + .map(|(from, set)| Some((gid(*from)?, set.iter().map(|c| gid(*c)).collect::>>()?))) + .collect::>>()?; + ids.sort_by_key(|p| p.0.to_u16()); + let from = ids.iter().map(|p| p.0).collect(); + let sets = ids.into_iter().map(|p| AlternateSet::new(p.1)).collect(); + Some(SubstitutionLookup::Alternate(Lookup::new( + Default::default(), + vec![AlternateSubstFormat1::new(CoverageFormat1::new(from).into(), sets)], + ))) + }; + // Lookups: 0 init, 1 medi, 2 fina, 3 salt, 4 ss01, 5 cv01. + let lookups = SubstitutionLookupList::new(vec![ + single(&[('b', 'B'), ('t', 'T'), ('n', 'N')])?, + single(&[('b', 'v'), ('t', 'u'), ('n', 'm')])?, + single(&[('b', 'V'), ('t', 'U'), ('n', 'M'), ('I', 'A')])?, + alternates(&[('u', &['w', 'x', 'y']), ('U', &['W', 'X']), ('t', &['E']), ('v', &['z'])])?, + single(&[('u', 'w'), ('U', 'W'), ('t', 'E'), ('v', 'z')])?, + single(&[('m', 'd')])?, + ]); + // Features sorted by tag, as OpenType requires: cv01, fina, init, medi, salt, ss01. + let records: Vec = [(b"cv01", 5), (b"fina", 2), (b"init", 0), (b"medi", 1), (b"salt", 3), (b"ss01", 4)] + .into_iter() + .map(|(tag, lookup)| FeatureRecord::new(Tag::new(tag), Feature::new(None, vec![lookup]))) + .collect(); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0, 1, 2, 3, 4, 5])), vec![]))]); + builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// The family of [`aliased_joining_font`]. +pub const ALIASED_JOINING_FAMILY: &str = "Alias Jointest Sans"; + +/// [`joining_alternates_font`] with Latin `B` and the Arabic initial beh presentation form +/// U+FE91 both mapped to beh's initial glyph. Only the presentation form should insert beh. +pub fn aliased_joining_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + + let bytes = joining_alternates_font()?; + let font = skrifa::FontRef::new(&bytes).ok()?; + let source = skrifa::FontRef::new(SOURCE_SANS).ok()?; + let initial = write_fonts::types::GlyphId::new(source.charmap().map('B')?.to_u32()); + let mut mappings = + font.charmap().mappings().filter_map(|(c, g)| Some((char::from_u32(c)?, write_fonts::types::GlyphId::new(g.to_u32())))).collect::>(); + mappings.extend([('B', initial), ('\u{FE91}', initial)]); + let cmap = Cmap::from_mappings(mappings).ok()?; + let name = name_table(&[ + (1, ALIASED_JOINING_FAMILY), + (2, "Regular"), + (4, "Alias Jointest Sans Regular"), + (5, "Version 1.000"), + (6, "AliasJointestSans-Regular"), + ])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_table(&cmap).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// The family and private-use character of [`contextual_fallback_font`]. +pub const CONTEXTUAL_FALLBACK_FAMILY: &str = "Contextual Fallbacktest Sans"; +pub const CONTEXTUAL_FALLBACK_CHAR: char = '\u{F6084}'; + +/// A fallback fixture with coverage no bundled face shares: `ccmp` draws its private-use +/// character (n) as m, whose `cv01` alternate d has no applicable character-style feature. +pub fn contextual_fallback_font() -> Option> { + use write_fonts::tables::gsub::{Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + let base = remapped_source_sans( + CONTEXTUAL_FALLBACK_FAMILY, + "ContextualFallbacktestSans-Regular", + &[(CONTEXTUAL_FALLBACK_CHAR, 'n'), ('a', 'a'), (' ', ' ')], + )?; + let font = skrifa::FontRef::new(&base).ok()?; + let [n, m, d] = source_sans_gids(['n', 'm', 'd'])?; + let single = |from, to| { + SubstitutionLookup::Single(Lookup::new( + Default::default(), + vec![SingleSubst::from(SingleSubstFormat2::new(CoverageFormat1::new(vec![GlyphId16::new(from)]).into(), vec![GlyphId16::new(to)]))], + )) + }; + let lookups = SubstitutionLookupList::new(vec![single(n, m), single(m, d)]); + let features = FeatureList::new(vec![ + FeatureRecord::new(Tag::new(b"ccmp"), Feature::new(None, vec![0])), + FeatureRecord::new(Tag::new(b"cv01"), Feature::new(None, vec![1])), + ]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0, 1])), vec![]))]); + let mut builder = FontBuilder::new(); + builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`ligature_caret_font`]. pub const LIGATURE_CARET_FAMILY: &str = "Carettest Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index bcb233b8c3..30713d33aa 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -13,6 +13,7 @@ mod composite; mod devanagari; mod embed; mod fit; +mod glyph_alternates; mod hang; mod harakat; mod inline; diff --git a/crates/text/src/tests/glyph_alternates.rs b/crates/text/src/tests/glyph_alternates.rs new file mode 100644 index 0000000000..6a47a3b0ee --- /dev/null +++ b/crates/text/src/tests/glyph_alternates.rs @@ -0,0 +1,504 @@ +//! A feature with a value (`salt=2`) on some characters of an Arabic word: the font's alternate +//! for them, with the word shaped once so that every other letter keeps its joining form and the +//! letters still touch. Read from a font built in code (and, when it is installed, the owner's +//! Kufi Murabba PIIIX). + +use kurbo::Point; +use vectorcraft_doc::{CharStyle, TextRun}; + +use crate::test_fonts::{JOINING_ALTERNATES_FAMILY, JOINING_LETTERS, joining_alternates_font, source_sans_gid}; +use crate::*; + +const BEH: &str = "\u{0628}"; +const TEH: &str = "\u{062A}"; +const NOON: &str = "\u{0646}"; + +fn db() -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + db.add_font(joining_alternates_font().expect("the test font builds")); + db +} + +fn style(family: &str, features: &[&str]) -> CharStyle { + CharStyle { font_family: family.into(), size: 100.0, features: features.iter().map(|f| f.to_string()).collect(), ..CharStyle::default() } +} + +/// `text` as one text object in `family`, each piece with its own features. +fn set(db: &FontDb, family: &str, pieces: &[(&str, &[&str])]) -> Vec { + let mut text = TextObject::point(Point::ZERO, "", style(family, &[])); + text.runs = pieces.iter().map(|(t, features)| TextRun { text: t.to_string(), style: style(family, features), inline: None }).collect(); + // In reading order of the characters, whichever way the line runs. + let mut glyphs = layout(db, &text).glyphs; + glyphs.sort_by_key(|g| g.byte); + glyphs +} + +fn joined(db: &FontDb, pieces: &[(&str, &[&str])]) -> Vec { + set(db, JOINING_ALTERNATES_FAMILY, pieces) +} + +fn gids(glyphs: &[PositionedGlyph]) -> Vec { + glyphs.iter().map(|g| g.gid).collect() +} + +fn g(c: char) -> u32 { + u32::from(source_sans_gid(c).expect("a Source Sans glyph")) +} + +/// The gap between each glyph and the next along the line, whichever way the line runs: 0 when the +/// letters touch. +fn gaps(glyphs: &[PositionedGlyph]) -> Vec { + // `origin` is the pen position at the glyph's left edge, also in right-to-left text. + let mut spans: Vec<(f64, f64)> = glyphs.iter().map(|g| (g.origin.x, g.origin.x + g.advance)).collect(); + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + spans.windows(2).map(|w| w[1].0 - w[0].1).collect() +} + +/// Beh, teh, noon: initial, medial and final in one word, teh drawn with `features`. +fn word(db: &FontDb, teh_features: &[&str]) -> Vec { + joined(db, &[(BEH, &[]), (TEH, teh_features), (NOON, &[])]) +} + +/// Without a feature the word is beh initial, teh medial, noon final. +#[test] +fn the_fixture_word_takes_its_joining_forms() { + let db = db(); + assert_eq!(gids(&joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])])), [g('B'), g('u'), g('M')]); + assert_eq!(JOINING_LETTERS.len(), 4); +} + +/// `salt=N` on the middle letter draws the font's Nth alternate for it, its neighbours keep their +/// initial and final forms (they are not shaped as lone letters), and the letters still touch. +#[test] +fn a_salt_value_on_one_letter_picks_its_alternate_and_keeps_the_word_joined() { + let db = db(); + let plain = word(&db, &[]); + assert_eq!(gids(&plain), [g('B'), g('u'), g('M')]); + let plain_gaps = gaps(&plain); + assert!(plain_gaps.iter().all(|gap| gap.abs() < 1e-6), "the word's glyphs touch: {plain_gaps:?}"); + for (features, middle) in [(&["salt"][..], 'w'), (&["salt=1"][..], 'w'), (&["salt=2"][..], 'x'), (&["salt=3"][..], 'y')] { + let glyphs = word(&db, features); + assert_eq!(gids(&glyphs), [g('B'), g(middle), g('M')], "{features:?}: the alternate between the word's own initial and final forms"); + let gap = gaps(&glyphs); + assert!(gap.iter().all(|gap| gap.abs() < 1e-6), "{features:?}: the letters touch: {gap:?}"); + } + // An alternate number the font has no glyph for, and the feature off, draw the default glyph. + for features in [&["salt=4"][..], &["-salt"][..], &["salt=0"][..], &["salt=2", "-salt"][..], &["ss03"][..]] { + assert_eq!(gids(&word(&db, features)), [g('B'), g('u'), g('M')], "{features:?}"); + } + // The last value of a feature wins. + assert_eq!(gids(&word(&db, &["salt=3", "salt=2"]))[1], g('x')); + // The font's salt lookup comes before its ss01 lookup, so salt takes the glyph first. + assert_eq!(gids(&word(&db, &["salt=2", "ss01"]))[1], g('x')); +} + +/// A feature on the first or the last letter of the word does not split it either. +#[test] +fn a_feature_on_an_end_letter_does_not_break_the_joining() { + let db = db(); + for (pieces, expected) in [ + (vec![(BEH, &["salt=2"][..]), (TEH, &[][..]), (NOON, &[][..])], [g('B'), g('u'), g('M')]), + (vec![(BEH, &[][..]), (TEH, &[][..]), (NOON, &["salt=2"][..])], [g('B'), g('u'), g('M')]), + (vec![(BEH, &["ss01"][..]), (TEH, &["ss01"][..]), (NOON, &["ss01"][..])], [g('B'), g('w'), g('M')]), + ] { + let glyphs = joined(&db, &pieces); + assert_eq!(gids(&glyphs), expected, "{pieces:?}"); + assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-6), "{pieces:?}"); + } +} + +/// The alternates of a letter depend on the form it takes where it stands: the final teh has two, +/// the medial teh three, so `salt=3` is an alternate of one and nothing for the other. +#[test] +fn the_alternate_a_value_picks_depends_on_the_letters_position() { + let db = db(); + let medial = |features: &[&str]| gids(&word(&db, features))[1]; + let final_form = |features: &[&str]| gids(&joined(&db, &[(BEH, &[]), (TEH, features)]))[1]; + assert_eq!((medial(&["salt=3"]), final_form(&["salt=3"])), (g('y'), g('U')), "no third alternate for the final form"); + assert_eq!((medial(&["salt=2"]), final_form(&["salt=2"])), (g('x'), g('X'))); + // A letter alone takes the isolated form's alternate. + assert_eq!(gids(&joined(&db, &[(TEH, &["salt"])])), [g('E')]); + assert_eq!(gids(&joined(&db, &[(TEH, &[])])), [g('t')]); +} + +/// A run with its own feature next to runs of another style in the word shapes with them: the +/// same glyph ids and places as a word typed in one run, when the feature changes nothing. +#[test] +fn runs_that_differ_only_in_features_shape_as_one_word() { + let db = db(); + let one_run = joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])]); + let three_runs = joined(&db, &[(BEH, &[]), (TEH, &["salt=9"]), (NOON, &["-liga"])]); + assert_eq!(gids(&three_runs), gids(&one_run)); + let (a, b): (Vec, Vec) = (one_run.iter().map(|g| g.origin.x).collect(), three_runs.iter().map(|g| g.origin.x).collect()); + assert_eq!(a, b, "the same places"); +} + +/// Where the owner's Kufi Murabba PIIIX is installed. +const KUFI_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Regular.ttf"; + +/// The owner's Heavy weight of it, the one a final yeh was reported against. +const KUFI_HEAVY_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Heavy.ttf"; + +/// The Kufi Murabba PIIIX of the font's author in a font database of its own, `None` when it is not +/// installed (the test then has nothing to read). When the file is there, a face that does not +/// load is a failure, never a skip. +fn kufi() -> Option<(FontDb, std::sync::Arc)> { + kufi_weight(KUFI_FILE, "Regular") +} + +/// [`kufi`] for the installed file `file` (under the home folder), whose face has style `style`. +fn kufi_weight(file: &str, style: &str) -> Option<(FontDb, std::sync::Arc)> { + let path = std::path::Path::new(&std::env::var_os("HOME")?).join(file); + if !path.exists() { + eprintln!("skipped: {} is not installed", path.display()); + return None; + } + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{} exists but cannot be read: {e}", path.display())); + let db = FontDb::with_font_dirs(vec![]); + let added = db.add_font(bytes); + assert!(added > 0, "{} exists but no face of it loaded", path.display()); + let face = db.face("Kufi Murabba PIIIX", style).expect("a face"); + assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", style), "the installed font's face, not a fallback"); + Some((db, face)) +} + +/// The word al-Khwarizmi: alef, lam, khah, waw, alef, reh, zain, meem, farsi yeh. The lam, khah and +/// waw join (initial, medial, final); the letters around them do not join forward. +const KHWARIZMI: &str = "الخوارزمی"; +/// The medial khah, the middle letter of the joined lam-khah-waw. +const KHAH: usize = 2; + +/// The word with `features` on the character `at` only, shaped as one piece (the runs differ only +/// in features), in reading order of the characters. +fn khwarizmi(db: &FontDb, family: &str, at: usize, features: &[&str]) -> Vec { + let letters: Vec = KHWARIZMI.chars().collect(); + let part = |r: std::ops::Range| -> String { letters[r].iter().collect() }; + let (before, middle, after) = (part(0..at), part(at..at + 1), part(at + 1..letters.len())); + set(db, family, &[(&before, &[]), (&middle, features), (&after, &[])]) +} + +/// The gap between the drawn outlines of the letters that join (the lam, khah and waw), along the +/// line: 0 when the ink of one reaches the ink of the next. Unlike [`gaps`], which only checks that +/// the pen advances by each glyph's advance, this reads the glyphs drawn. +fn ink_gaps(glyphs: &[PositionedGlyph]) -> Vec { + use kurbo::Shape; + let mut spans: Vec<(f64, f64)> = glyphs[KHAH - 1..=KHAH + 1].iter().map(|g| (g.outline.bounding_box().x0, g.outline.bounding_box().x1)).collect(); + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + spans.windows(2).map(|w| w[1].0 - w[0].1).collect() +} + +/// One line of what the shaper made, for a failure message and `--nocapture`. +fn describe(glyphs: &[PositionedGlyph]) -> String { + glyphs.iter().map(|g| format!("{}@{:.1}+{:.1}", g.gid, g.origin.x, g.advance)).collect::>().join(" ") +} + +/// The Kufi Murabba PIIIX of the font's author, when it is installed: the medial khah of a word +/// takes each of its `salt` alternates, every other letter keeps the glyph it has without one and +/// the letters still touch. +#[test] +fn kufi_murabba_alternates_keep_the_word_joined() { + let Some((db, face)) = kufi() else { return }; + let family = face.family.clone(); + let plain = khwarizmi(&db, &family, KHAH, &[]); + assert_eq!(plain.len(), KHWARIZMI.chars().count(), "one glyph for each letter: {}", describe(&plain)); + assert!(gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the word joins without a gap: {:?}", gaps(&plain)); + assert!(ink_gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the drawn letters touch: {:?}", ink_gaps(&plain)); + let default = plain[KHAH].gid; + assert_eq!(face.glyph_name(default).as_deref(), Some("khah.medi"), "the khah in the middle of the word takes its medial form"); + let salt = + face.substitutions(default).into_iter().find(|s| &s.feature == b"salt" && s.glyphs.len() >= 2).expect("the medial khah has salt alternates"); + for (i, alternate) in salt.glyphs.iter().enumerate() { + let value = format!("salt={}", i + 1); + let glyphs = khwarizmi(&db, &family, KHAH, &[value.as_str()]); + assert_eq!(glyphs.len(), plain.len(), "{value}: {}", describe(&glyphs)); + assert_eq!(glyphs[KHAH].gid, *alternate, "{value}: the alternate"); + assert_ne!(glyphs[KHAH].gid, default, "{value}: not the default glyph"); + assert_eq!(face.glyph_name(*alternate), Some(format!("khah.medi.alt{}", i + 1)), "{value}: an alternate of the medial form"); + for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != KHAH) { + assert_eq!(a.gid, b.gid, "{value}: the letter {n} keeps its form: {}", describe(&glyphs)); + } + assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: no gap: {:?}", gaps(&glyphs)); + assert!(ink_gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: the drawn letters touch: {:?}", ink_gaps(&glyphs)); + } +} + +/// The stylistic sets `ss01` to `ss05` on a letter in the middle of a joined group: the glyph the +/// font's set gives its medial form (the default when it has none), the same forms for the others, +/// no gap. +#[test] +fn kufi_murabba_stylistic_sets_on_a_middle_letter_keep_the_word_joined() { + let Some((db, face)) = kufi() else { return }; + let family = face.family.clone(); + let plain = khwarizmi(&db, &family, KHAH, &[]); + let default = plain[KHAH].gid; + let mut changed = 0; + for set_number in 1..=5 { + let tag = format!("ss{set_number:02}"); + let wanted = face.substitutions(default).into_iter().find(|s| s.feature == tag.as_bytes() && !s.glyphs.is_empty()).map(|s| s.glyphs[0]); + let glyphs = khwarizmi(&db, &family, KHAH, &[tag.as_str()]); + assert_eq!(glyphs.len(), plain.len(), "{tag}: {}", describe(&glyphs)); + assert_eq!(glyphs[KHAH].gid, wanted.unwrap_or(default), "{tag}: the set's glyph for the medial khah"); + changed += usize::from(wanted.is_some_and(|w| w != default)); + for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != KHAH) { + assert_eq!(a.gid, b.gid, "{tag}: the letter {n} keeps its form: {}", describe(&glyphs)); + } + assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{tag}: no gap: {:?}", gaps(&glyphs)); + assert!(ink_gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{tag}: the drawn letters touch: {:?}", ink_gaps(&glyphs)); + } + assert!(changed >= 1, "the font has stylistic sets for the medial khah"); +} + +/// The `GSUB` reader gives the alternate set of each glyph (position in the set = the feature's +/// value), single substitutions, the feature that reaches a glyph, names and the glyph count. +#[test] +fn the_gsub_reader_lists_alternate_sets_singles_and_the_feature_reaching_a_glyph() { + use crate::{Substitution, SubstitutionKind}; + let db = db(); + let face = db.face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face"); + let salt = Substitution { feature: *b"salt", kind: SubstitutionKind::Alternate, glyphs: vec![g('w'), g('x'), g('y')] }; + let ss01 = Substitution { feature: *b"ss01", kind: SubstitutionKind::Single, glyphs: vec![g('w')] }; + assert_eq!(face.substitutions(g('u')), [salt.clone(), ss01.clone()], "salt (an alternate set) before ss01 (a single)"); + assert_eq!(salt.value_for(g('x')), Some(2)); + assert_eq!(salt.value_for(g('u')), None); + assert_eq!((ss01.value_for(g('w')), ss01.value_for(g('x'))), (Some(1), None)); + // Each form of a letter has its own alternates. + let sets = |c| face.substitutions(g(c)).iter().map(|s| (s.feature, s.glyphs.len())).collect::>(); + assert_eq!(sets('U'), [(*b"salt", 2), (*b"ss01", 1)]); + assert_eq!(sets('t'), [(*b"salt", 1), (*b"ss01", 1)]); + assert!(sets('B').is_empty() && sets('M').is_empty(), "the initial and final forms of the others have none"); + assert!(face.substitutions(face.glyph_count()).is_empty(), "past the glyphs"); + // The feature that reaches a glyph no character maps: the listed ones first, then positional forms. + assert_eq!(face.feature_reaching(g('x')), Some(*b"salt")); + assert_eq!(face.feature_reaching(g('w')), Some(*b"salt"), "salt ranks before ss01"); + assert_eq!(face.feature_reaching(g('B')), Some(*b"init")); + assert_eq!(face.feature_reaching(g('m')), Some(*b"medi")); + assert_eq!(face.feature_reaching(g('A')), Some(*b"fina")); + assert_eq!(face.feature_reaching(g('b')), None, "the isolated form is reached by no feature"); + // Names and the glyph count. + let bundled = db.face(FALLBACK_FAMILY, "Regular").expect("the bundled face"); + assert_eq!(face.glyph_count(), bundled.glyph_count()); + assert_eq!(face.glyph_name(g('B')).as_deref(), Some("B")); + assert_eq!(face.glyph_name(face.glyph_count()), None); + // `alternates` is the same reading, for a character. + let listed: Vec<([u8; 4], u32)> = face.alternates('\u{062A}').into_iter().map(|a| (a.feature, a.gid)).collect(); + assert_eq!(listed, [(*b"salt", g('E'))], "the isolated teh"); +} + +/// The alternates of a letter as it stands in its word: the glyph the shaper makes for it there, +/// its alternates, and the feature value that draws each on that letter alone. +#[test] +fn alternates_in_context_list_the_letters_positional_form_and_the_feature_that_draws_each() { + let db = db(); + let text = |teh: &[&str]| { + let mut t = TextObject::point(Point::ZERO, "", style(JOINING_ALTERNATES_FAMILY, &[])); + t.runs = [(BEH, &[][..]), (TEH, teh), (NOON, &[][..])] + .into_iter() + .map(|(s, f)| TextRun { text: s.to_string(), style: style(JOINING_ALTERNATES_FAMILY, f), inline: None }) + .collect(); + t + }; + let teh = BEH.len(); + let found = alternates_in_context(&db, &text(&[]), teh).expect("the medial teh"); + assert_eq!((found.default, found.current), (g('u'), g('u'))); + let listed: Vec<(u32, Option)> = found.alternates.iter().map(|a| (a.gid, a.apply.clone())).collect(); + assert_eq!( + listed, + [(g('w'), Some("salt".to_string())), (g('x'), Some("salt=2".to_string())), (g('y'), Some("salt=3".to_string()))], + "the medial form's three alternates, not the isolated form's one" + ); + // Drawn with salt=2 now: the default is still the form without it. + let now = alternates_in_context(&db, &text(&["salt=2"]), teh).expect("the medial teh"); + assert_eq!((now.default, now.current, now.alternates.len()), (g('u'), g('x'), 3)); + // The first and last letters take their own forms. + let beh = alternates_in_context(&db, &text(&[]), 0).expect("the initial beh"); + assert_eq!((beh.default, beh.alternates.len()), (g('B'), 0)); + let noon = alternates_in_context(&db, &text(&[]), teh + TEH.len()).expect("the final noon"); + assert_eq!((noon.default, noon.alternates.len()), (g('M'), 0)); + assert_eq!(alternates_in_context(&db, &text(&[]), 1), None, "not a character boundary"); + // alternate_changes: the character keeps only the alternate feature it is given. + let own = |f: &[&str]| f.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig", "-ss02"]), Some("salt=3")), ["-ss01", "salt=3"]); + assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig"]), None), ["-ss01", "-salt"]); + assert!(alternate_changes(&own(&["salt=2", "dlig"]), Some("salt=2")).is_empty()); + assert_eq!(alternate_changes(&own(&["salt"]), Some("salt=2")), ["salt=2"]); + assert!(alternate_changes(&own(&["dlig"]), None).is_empty()); + assert!(is_alternate_feature("-ss03") && is_alternate_feature("salt=2") && !is_alternate_feature("dlig") && !is_alternate_feature("titl")); +} + +/// A glyph no feature of the style draws is listed without one, and a feature's glyph is found +/// with the feature that draws it. +#[test] +fn a_variant_no_feature_draws_has_no_feature_to_apply() { + use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let st = CharStyle { font_family: alternates_family(true).into(), size: 100.0, ..CharStyle::default() }; + let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", st), 1).expect("the a"); + let apply = |gid: u32| found.alternates.iter().find(|a| a.gid == gid).map(|a| a.apply.clone()); + assert_eq!(found.default, face.glyph_for('a')); + assert_eq!(apply(u1), Some(Some("swsh=2".to_string())), "swsh before aalt"); + assert_eq!(apply(u2), Some(Some("ss01".to_string()))); + assert_eq!(apply(u3), Some(Some("swsh".to_string()))); + assert_eq!(apply(u4), Some(Some("ss02".to_string()))); + assert_eq!(apply(face.glyph_for('c')), Some(None), "cv01 has no style tag"); + assert!(face.unencoded_glyphs().contains(&u1) && !face.unencoded_glyphs().contains(&face.glyph_for('a'))); +} + +/// The alternates are listed `aalt` first, then `salt`, the stylistic sets, `swsh` and `cv01`, each +/// glyph once under the first feature that lists it, whichever feature draws it in the text. +#[test] +fn alternates_are_listed_aalt_first_under_the_first_feature_that_lists_them() { + use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let st = CharStyle { font_family: alternates_family(true).into(), size: 100.0, ..CharStyle::default() }; + let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", st), 1).expect("the a"); + let listed: Vec<(u32, [u8; 4], Option)> = found.alternates.iter().map(|a| (a.gid, a.feature, a.apply.clone())).collect(); + let (a, b, c) = (face.glyph_for('a'), face.glyph_for('b'), face.glyph_for('c')); + assert_eq!( + listed, + [ + (a, *b"aalt", Some("aalt".to_string())), + (u1, *b"aalt", Some("swsh=2".to_string())), + (b, *b"aalt", Some("salt".to_string())), + (u2, *b"ss01", Some("ss01".to_string())), + (u4, *b"ss02", Some("ss02".to_string())), + (u3, *b"swsh", Some("swsh".to_string())), + (c, *b"cv01", None), + ], + "aalt's set first, with the default glyph it lists; b is aalt's though salt draws it" + ); +} + +/// A glyph that more than its character makes (the lam-alef ligature) has no alternates of its own +/// there, though the font substitutes it (`salt`). +#[test] +fn a_ligature_has_no_alternates_in_context() { + use crate::test_fonts::{LAM_ALEF_SALT, lam_alef_family, lam_alef_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(lam_alef_font(b"rlig").expect("the test font builds")); + let family = lam_alef_family(b"rlig"); + let face = db.face(family, "Regular").expect("the test face"); + let lam_alef = TextObject::point(Point::ZERO, "\u{0644}\u{0627}", style(family, &[])); + let ligature = g('H'); + assert_eq!( + face.substitutions(ligature).iter().map(|s| s.glyphs.clone()).collect::>(), + [vec![g(LAM_ALEF_SALT)]], + "the font gives the ligature an alternate" + ); + let found = alternates_in_context(&db, &lam_alef, 0).expect("the ligature"); + assert_eq!((found.default, found.current), (ligature, ligature)); + assert!(found.alternates.is_empty(), "{:?}", found.alternates); + // The lam alone is its own character: it has no alternates because the font gives it none. + let alone = alternates_in_context(&db, &TextObject::point(Point::ZERO, "\u{0644}", style(family, &[])), 0).expect("the lam"); + assert_eq!(alone.default, g('l')); +} + +/// A word is shaped as one piece (to 256 characters each side of the selected one), the selected +/// character found in it by its offset: a letter deep in a long word and the letters next to a +/// space, a line feed or a line separator each get the form their place in their own word gives. +#[test] +fn the_probe_finds_the_character_in_its_word_across_a_long_word_and_breaks() { + let db = db(); + let text = |s: &str| TextObject::point(Point::ZERO, s, style(JOINING_ALTERNATES_FAMILY, &[])); + let default_of = |t: &TextObject, byte: usize| { + let found = alternates_in_context(&db, t, byte).expect("a character"); + (found.default, found.alternates.len()) + }; + // 700 letters: beh, 698 teh, noon. Each is found at its own offset, 300 of them past the + // 256 characters either side that are shaped. + let long = text(&format!("{BEH}{}{NOON}", TEH.repeat(698))); + assert_eq!(default_of(&long, 0), (g('B'), 0), "the first letter is initial"); + assert_eq!(default_of(&long, 2 * 300), (g('u'), 3), "a medial teh deep in the word, with its alternates"); + assert_eq!(default_of(&long, 2 * 699), (g('M'), 0), "the last letter is final"); + // Two words: the break leaves each its own initial and final letters. The teh before the break + // ends its word (final) and the one after starts the next (initial), not medial. + for brk in [" ", "\n", "\u{2028}", "\u{2029}"] { + let two = text(&format!("{BEH}{TEH}{brk}{TEH}{NOON}")); + let after = 2 * 2 + brk.len(); + assert_eq!(default_of(&two, 2), (g('U'), 2), "{brk:?}: final teh before the break, with its two alternates"); + assert_eq!(default_of(&two, after), (g('T'), 0), "{brk:?}: initial teh after it"); + assert_eq!(default_of(&two, after + 2), (g('M'), 0), "{brk:?}: the noon after it is final"); + } +} + +/// A word of alternates longer than the candidates tried (512) leaves the features after it +/// untried: `aalt`'s 512 values use them all, so `salt`'s glyph is listed but no feature is applied +/// for it. +#[test] +fn the_candidates_tried_are_capped() { + use crate::test_fonts::{CAPPED_CANDIDATES_FAMILY, capped_candidates_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(capped_candidates_font().expect("the test font builds")); + let face = db.face(CAPPED_CANDIDATES_FAMILY, "Regular").expect("the test face"); + let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", style(CAPPED_CANDIDATES_FAMILY, &[])), 1).expect("the a"); + let listed: Vec<(u32, Option)> = found.alternates.iter().map(|a| (a.gid, a.apply.clone())).collect(); + assert_eq!(listed, [(face.glyph_for('b'), Some("aalt".to_string())), (face.glyph_for('c'), None)]); +} + +/// A feature on a one-byte character (Latin) reaches that character: the range of a feature is +/// its source bytes, and harfrust applies a range `a..b` to clusters `a..b - 1` unless the end is +/// inclusive, which would leave a one-byte character out. +#[test] +fn a_feature_on_a_one_byte_character_reaches_it() { + use crate::test_fonts::{alternates_family, alternates_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let st = |features: &[&str]| style(alternates_family(true), features); + let mut text = TextObject::point(Point::ZERO, "", st(&[])); + text.runs = vec![ + TextRun { text: "x".into(), style: st(&[]), inline: None }, + TextRun { text: "a".into(), style: st(&["salt"]), inline: None }, + TextRun { text: "y".into(), style: st(&[]), inline: None }, + ]; + let gids: Vec = layout(&db, &text).glyphs.iter().map(|g| g.gid).collect(); + assert_eq!(gids, [face.glyph_for('x'), face.glyph_for('b'), face.glyph_for('y')], "salt draws b for the a alone"); +} + +/// The owner's Kufi Murabba PIIIX Heavy, with the final letter of al-Khwarizmi as Persian yeh +/// (U+06CC), Arabic yeh (U+064A) or alef maksura (U+0649): in each the letter takes its own final +/// form, the byte offset of the last character finds its alternates, `salt` to `salt=6` draw them +/// on that letter alone with the rest of the word and its joining untouched, and the glyph an +/// alternate is (`yehfarsi.fina.alt2`) is traced back to its letter and its place in the word. +#[test] +fn kufi_murabba_heavy_final_letters_alternates_belong_to_their_letter() { + let Some((db, face)) = kufi_weight(KUFI_HEAVY_FILE, "Heavy") else { return }; + let family = face.family.clone(); + for (letter, base) in [('\u{06CC}', "yehfarsi"), ('\u{064A}', "yeh"), ('\u{0649}', "alefmaksura")] { + let head: String = KHWARIZMI.chars().take(KHWARIZMI.chars().count() - 1).collect(); + let word = format!("{head}{letter}"); + let last = word.len() - letter.len_utf8(); + let letter = letter.to_string(); + let plain = set(&db, &family, &[(&head, &[]), (&letter, &[])]); + assert_eq!(plain.len(), KHWARIZMI.chars().count(), "{base}: one glyph for each letter: {}", describe(&plain)); + assert_eq!(face.glyph_name(plain[plain.len() - 1].gid), Some(format!("{base}.fina")), "{base}: the letter ends the word in its final form"); + let mut text = TextObject::point(Point::ZERO, "", style(&family, &[])); + text.runs = vec![TextRun { text: word.clone(), style: style(&family, &[]), inline: None }]; + let found = alternates_in_context(&db, &text, last).expect("the final letter"); + assert_eq!((found.default, found.current), (plain[plain.len() - 1].gid, plain[plain.len() - 1].gid), "{base}"); + assert_eq!(found.alternates.len(), 6, "{base}: the six alternates of the final form"); + for (n, a) in found.alternates.iter().enumerate() { + let tag = if n == 0 { "salt".to_string() } else { format!("salt={}", n + 1) }; + assert_eq!(a.apply.as_deref(), Some(tag.as_str()), "{base}: the feature that draws alternate {}", n + 1); + assert_eq!(face.glyph_name(a.gid), Some(format!("{base}.fina.alt{}", n + 1)), "{base}"); + let glyphs = set(&db, &family, &[(&head, &[]), (&letter, &[tag.as_str()])]); + assert_eq!(glyphs.len(), plain.len(), "{base} {tag}: {}", describe(&glyphs)); + assert_eq!(glyphs[glyphs.len() - 1].gid, a.gid, "{base} {tag}: the alternate"); + assert!( + glyphs.iter().zip(&plain).take(plain.len() - 1).all(|(x, y)| x.gid == y.gid), + "{base} {tag}: the rest of the word keeps its forms" + ); + let origin = face.glyph_origin(a.gid).expect("the alternate comes from a letter"); + assert_eq!(origin.ch.to_string(), letter, "{base}: {} is a form of its own letter", face.glyph_name(a.gid).unwrap_or_default()); + assert_eq!(origin.position(), Some(JoiningPosition::Final), "{base}: and of its end of a word"); + } + } + // Glyph 820 of the owner's file is yehfarsi.fina.alt2: Persian yeh's, at the end of a word. + let g820 = face.glyph_origin(820).expect("glyph 820 comes from a letter"); + assert_eq!((face.glyph_name(820).as_deref(), g820.ch, g820.position()), (Some("yehfarsi.fina.alt2"), '\u{06CC}', Some(JoiningPosition::Final))); +} diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index 9bb7700d66..b50b39da83 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -152,13 +152,13 @@ fn paint_only_differences_are_told_apart() { assert_eq!(harakat_ranges("اللَّهِ", 0.."اللَّهِ".len()), [6..10, 12..14], "shadda and fatha together"); } -/// The allocation-free comparison must keep the previous comparison's complete contract, -/// including attributes that affect layout rather than shaping and non-reflexive file values. +/// Compare every drawing attribute without allocations, retaining PartialEq for non-reflexive +/// values while allowing features already resolved by shaping. #[test] -fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { +fn paint_only_keeps_every_drawing_attribute_and_partial_equality() { use vectorcraft_doc::{CharAlign, CharPosition, Dash, Digits, FontHeightReference, GlyphChoice, LineCap, LineJoin, ParaDirection, StrokeAlign}; - fn previous(a: &CharStyle, b: &CharStyle) -> bool { + fn normalized(a: &CharStyle, b: &CharStyle) -> bool { let mut b = b.clone(); b.fill = a.fill.clone(); b.stroke = a.stroke.clone(); @@ -172,12 +172,13 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { b.stroke_align = a.stroke_align; b.style_name = a.style_name.clone(); b.kerning_before = a.kerning_before; + b.features = a.features.clone(); *a == b } fn check(a: &CharStyle, b: &CharStyle, want: bool, name: &str) { for (left, right) in [(a, b), (b, a)] { - assert_eq!(paint_only(left, right), previous(left, right), "{name}: old contract"); + assert_eq!(paint_only(left, right), normalized(left, right), "{name}: normalized contract"); } assert_eq!(paint_only(a, b), want, "{name}"); } @@ -213,7 +214,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { field!(underline, true, false); field!(strikethrough, true, false); field!(all_caps, true, false); - field!(features, vec!["-liga".into()], false); + field!(features, vec!["-liga".into()], true); field!(position, CharPosition::Superscript(Default::default()), false); field!(small_caps, Some(80.0), false); field!(char_align, CharAlign::EmBoxTop, false); @@ -294,6 +295,42 @@ fn synthetic_harakat_keep_geometry_with_and_without_gdef_marks() { } } +#[test] +fn feature_only_harakat_boundaries_keep_attachment_and_ownership() { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(crate::test_fonts::lam_alef_font(b"rlig").expect("the test font builds")), 1); + let family = crate::test_fonts::lam_alef_family(b"rlig"); + let face = db.face(family, "Regular").expect("the test face"); + const WORD: &str = "بَبَبَ"; + let plain = TextObject::point(Point::ZERO, WORD, style(family)); + let before = layout(&db, &plain); + assert_eq!(before.glyphs.len(), 6, "three bases with their attached marks"); + assert!(before.glyphs.iter().any(|g| is_gdef_mark(&face, g.gid))); + for feature_marks in [false, true] { + for coloured in [false, true] { + let mut text = plain.clone(); + for (byte, ch) in WORD.char_indices() { + crate::edit::style_range(&mut text.runs, byte, byte + ch.len_utf8(), |st| { + if is_harakat(ch) == feature_marks { + st.features = vec!["salt=2".into()]; + } + if coloured && is_harakat(ch) { + st.fill = red(); + } + }); + } + let after = layout(&db, &text); + assert_eq!(geometry(&after), geometry(&before), "features on marks {feature_marks}, coloured {coloured}"); + for glyph in &after.glyphs { + let mark = is_gdef_mark(&face, glyph.gid); + let owner = &run_of(&text, glyph).style; + assert_eq!(!owner.features.is_empty(), mark == feature_marks, "each glyph keeps its own character's features"); + assert_eq!(owner.fill == red(), coloured && mark, "only marks take the mark paint"); + } + } + } +} + /// A range is normalised as `edit::style_range` takes one: clamped, each end moved back to the /// start of its character, put in order. Never a silent empty result for a usable range. #[test] diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index a1e06887a7..d7d8334400 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -629,6 +629,66 @@ fn an_upright_ligature_takes_its_caret_down_its_cell() { assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900)); } +#[test] +fn feature_only_settings_keep_a_ligature_whole() { + let db = db_with(vertical_ligature_caret_font(280)); + for vertical in [false, true] { + let mut text = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + text.vertical = vertical; + let before = layout(&db, &text); + assert_eq!(before.glyphs.len(), 1, "the fixture ligates fi"); + let plain = &before.glyphs[0]; + for byte in [0, 1] { + for feature in ["salt=2", "ss03"] { + let mut featured = text.clone(); + crate::edit::style_range(&mut featured.runs, byte, byte + 1, |st| st.features = vec![feature.into()]); + let after = layout(&db, &featured); + assert_eq!(after.glyphs.len(), 1, "{feature} at {byte}, vertical {vertical}: an unchanged fi stays ligated"); + let glyph = &after.glyphs[0]; + assert_eq!((glyph.gid, glyph.byte, glyph.len), (plain.gid, 0, 2)); + assert_eq!(glyph.origin, plain.origin); + assert_eq!(glyph.xf, plain.xf); + assert_eq!(glyph.advance, plain.advance); + assert_eq!(glyph.carets, plain.carets); + } + } + } +} + +#[test] +fn drawing_differences_still_split_a_feature_styled_ligature() { + let db = db_with(ligature_caret_font(None)); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test face"); + let changes: [fn(&mut CharStyle); 3] = + [|st| st.fill = vectorcraft_doc::color::Paint::None, |st| st.baseline_shift = 10.0, |st| st.underline = true]; + for (i, change) in changes.into_iter().enumerate() { + for byte in [0, 1] { + let mut text = point("fi", LIGATURE_CARET_FAMILY); + crate::edit::style_range(&mut text.runs, byte, byte + 1, |st| { + st.features = vec!["salt=2".into()]; + change(st); + }); + let after = layout(&db, &text); + assert_eq!(after.glyphs.iter().map(|g| g.gid).collect::>(), [face.glyph_for('f'), face.glyph_for('i')], "attribute {i} at {byte}"); + for (byte, glyph) in after.glyphs.iter().enumerate() { + assert_eq!((glyph.byte, glyph.len, glyph.run), (byte, 1, byte), "split glyphs keep their own drawing style"); + } + } + } +} + +#[test] +fn a_ligature_feature_still_applies_only_to_its_character_range() { + let db = db_with(ligature_caret_font(None)); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test face"); + let mut text = point("fifi", LIGATURE_CARET_FAMILY); + crate::edit::style_range(&mut text.runs, 0, 1, |st| st.features = vec!["-liga".into()]); + let after = layout(&db, &text); + let glyphs = after.glyphs.iter().map(|g| (g.gid, g.byte, g.len)).collect::>(); + let ligature = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("the fixture's unencoded ligature glyph")); + assert_eq!(glyphs, [(face.glyph_for('f'), 0, 1), (face.glyph_for('i'), 1, 1), (ligature, 2, 2)]); +} + /// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets, so /// setting one of them on the f of "fi" keeps the ligature. Vertical type reads them per glyph, so /// there the f is shaped apart from the i. diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c397231495..54a4d131bd 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -148,7 +148,8 @@ pub trait Tool: Send { false } /// Is a drag moving, scaling or rotating the selection (the canvas then hides the bounding box, - /// so only the art is seen going with the pointer, except during a handle resize)? + /// so only the art is seen going with the pointer, except during a handle resize, and during a + /// move with General › Show Bounding Box While Moving and Drawing on)? fn transforming(&self) -> bool { false } @@ -157,6 +158,18 @@ pub trait Tool: Send { fn resizing(&self) -> bool { false } + /// Is the transforming drag a move of the selection (an Alt-drag copy included)? With General › + /// Show Bounding Box While Moving and Drawing on, the canvas then draws the bounding box where + /// the drag has moved the selection. + fn moving(&self) -> bool { + false + } + /// Is a drag drawing a new shape that the tool shows the bounding box of once drawn? With + /// General › Show Bounding Box While Moving and Drawing on, the canvas then draws the box around + /// the shape as it is drawn. + fn drawing_boxed(&self) -> bool { + false + } /// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool /// with a stop selected takes Delete and the arrows)? fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 3af4a84ea7..2f177d5847 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -841,6 +841,10 @@ impl Tool for SelectionTool { matches!(self.state, State::Scaling { .. }) } + fn moving(&self) -> bool { + matches!(self.state, State::Moving { began: true, .. }) + } + fn transforming(&self) -> bool { match &self.state { State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true, diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 9069cda939..659e50a6a5 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -716,8 +716,10 @@ fn drag_moves_with_single_undo() { let cx = cx(&d, &s, &p); let mut t = SelectionTool::default(); assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)).is_empty()); + assert!(!t.moving(), "a press is not a move yet"); let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0)); assert_eq!(a[0], Action::Begin("Move".into())); + assert!(t.moving()); assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["matrix"][4] == 10.0)); let a = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 150.0)); assert_eq!(a, vec![Action::Commit]); @@ -737,9 +739,10 @@ fn escape_cancels_a_move_or_an_alt_drag_copy() { t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0).with_mods(m)); let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0).with_mods(m)); assert!(matches!(&a[1], Action::Preview(_, v) if v["copy"] == m.alt), "{what}: {a:?}"); - assert!(t.transforming(), "{what}"); + // A move or a copy, which the canvas can show the bounding box for. + assert!(t.transforming() && t.moving() && !t.resizing(), "{what}"); assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); - assert!(!t.busy() && !t.transforming(), "{what}"); + assert!(!t.busy() && !t.transforming() && !t.moving(), "{what}"); assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Measure { .. })), "{what}: no readout stays"); assert!(t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 150.0).with_mods(m)).is_empty(), "{what}: the drag is over"); assert!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 150.0).with_mods(m)).is_empty(), "{what}: the release commits nothing"); diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index 6609ef62aa..fb291796f7 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -668,6 +668,15 @@ impl Tool for ShapeTool { fn resizing(&self) -> bool { matches!(self.selection, Some(SelectionDrag::Scale { .. })) } + /// A move of the selection by its center point, as the Selection tool's move. + fn moving(&self) -> bool { + matches!(self.selection, Some(SelectionDrag::Move { began: true, .. })) + } + /// Drawing a shape with a tool that shows the selection's bounding box. The Line Segment tool + /// shows no box, so it reports none. + fn drawing_boxed(&self) -> bool { + shows_shape_widgets(self.id) && self.began + } /// Adjusting a widget (the canvas hides the bounding box meanwhile, as for the Selection tool). fn transforming(&self) -> bool { match &self.widget { diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index 663cdab3fd..c76eef404e 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -468,7 +468,7 @@ fn a_shape_tool_drags_the_selected_shapes_widgets() { matches!(a.last(), Some(Action::Preview(cmd, v)) if cmd == "object.setLiveShape" && v["radius"].as_f64().is_some_and(|r| r > 0.0)), "{id}: {a:?}" ); - assert!(t.transforming(), "{id}"); + assert!(t.transforming() && !t.drawing_boxed() && !t.moving(), "{id}"); assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 130.0)), vec![Action::Commit], "{id}"); assert!(!t.busy() && !t.transforming(), "{id}"); // Escape cancels a widget drag. @@ -529,14 +529,15 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn // A drag moves the selection by the pointer's offset. let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 160.0)); assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{id}: {a:?}"); - assert!(t.transforming(), "{id}"); + // A move, which the canvas can show the bounding box for. + assert!(t.transforming() && t.moving() && !t.resizing() && !t.drawing_boxed(), "{id}"); assert_eq!( a.last(), Some(&Action::Preview("object.transform".into(), json!({"matrix": [1.0, 0.0, 0.0, 1.0, 20.0, 10.0], "copy": false}))), "{id}" ); assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 160.0)), vec![Action::Commit], "{id}"); - assert!(!t.busy() && !t.transforming(), "{id}"); + assert!(!t.busy() && !t.transforming() && !t.moving(), "{id}"); } // Alt pressed once the drag has begun copies, and the copy cursor shows, as the Selection // tool's do. @@ -581,6 +582,32 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn t.pointer(&c, &PointerEvent::new(PointerKind::Down, away.x, away.y)); let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 350.0, 340.0)); assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}"); + assert!(t.drawing_boxed() && !t.moving() && !t.transforming()); +} + +/// The shape tools that show the selection's bounding box report a shape being drawn once the drag +/// has begun, until the release or Escape. The Line Segment tool shows no box and reports none. +#[test] +fn a_shape_being_drawn_has_its_bounding_box_drawn_by_the_tools_that_show_one() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let c = cx(&d, &s, &p); + for id in ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment"] { + let mut t = ShapeTool::new(id); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert!(!t.drawing_boxed(), "{id}: not before the drag begins"); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 140.0)); + assert_eq!(t.drawing_boxed(), id != "lineSegment", "{id}"); + assert!(!t.moving() && !t.resizing(), "{id}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 140.0)); + assert!(!t.drawing_boxed(), "{id}: not after the release"); + // Escape ends the drag. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 140.0)); + t.key(&c, ToolKey::Escape, Mods::default()); + assert!(!t.drawing_boxed(), "{id}: not after Escape"); + } } /// With a shape tool, a drag on one of the eight handles of the selection's bounding box scales the @@ -599,7 +626,7 @@ fn a_shape_tool_scales_the_selection_by_its_bounding_box_handles() { assert!(t.overlays(&c).is_empty(), "{id}"); assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale".into())], "{id}"); // A resize: the canvas keeps the box on show at the changing bounds. - assert!(t.busy() && t.transforming() && t.resizing(), "{id}"); + assert!(t.busy() && t.transforming() && t.resizing() && !t.moving(), "{id}"); let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 230.0)); let want = scale_for_drag(Rect::new(100.0, 100.0, 200.0, 200.0), Handle::BottomRight, Point::new(220.0, 230.0), false, false); assert_eq!(a, vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(want), "copy": false}))], "{id}"); diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 5f0a30568f..a9f782b9bd 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -70,12 +70,25 @@ pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized /// colours and what noise and lighting make of them, and eight colours come within a few levels /// of every pixel; a photo's tones spread all through its range, and eight colours stay far from /// most of them (more than 6 levels, and more than a tenth of how far a single colour stays). +/// A gray picture's tones lie along one line instead of through the whole color cube, and eight +/// colors come nearer to them. A neutral picture (19 in 20 of its opaque pixels with their +/// channels within 8 levels of each other) is therefore also continuous tone when eight colors +/// stay more than 0.065 as far as a single color does, unless sixteen colors stay at most a fifth +/// as far as eight do. That exception holds for gray flat art of up to sixteen clean levels, where +/// sixteen colors come within about a level of every pixel. On gray photos sixteen colors stay +/// 0.18 to 0.48 as far as eight do, and on most anti-aliased or noisy gray flat art 0.24 to 0.5 +/// as far, so the exception holds for neither. pub(crate) fn continuous_tone(img: &Raster) -> bool { let hk = HistKey::new(img); let mut bins = vec![Bin::default(); 1 << 15]; + let (mut opaque, mut neutral) = (0u64, 0u64); for p in img.rgba.as_chunks::<4>().0 { if p[3] >= 128 { bins[hk.key(p[0], p[1], p[2])].add(p); + opaque += 1; + if p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]) <= 8 { + neutral += 1; + } } } let used: Vec = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect(); @@ -91,7 +104,11 @@ pub(crate) fn continuous_tone(img: &Raster) -> bool { if n > 0.0 { (sum / n).sqrt() } else { 0.0 } }; let eight = unsaid(8); - eight > 6.0 && eight > 0.1 * unsaid(1) + if eight <= 6.0 { + return false; + } + let one = unsaid(1); + eight > 0.1 * one || (20 * neutral >= 19 * opaque && eight > 0.065 * one && unsaid(16) > 0.2 * eight) } /// Drop unused palette entries and merge duplicates. diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 0d2b725efb..34192c8b43 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1396,6 +1396,77 @@ fn a_noisy_gradient_keeps_its_tones_without_specks() { assert!(res.paths.len() <= 2 * res.palette.len(), "{} paths of {} colours", res.paths.len(), res.palette.len()); } +/// A shaded ball and a lit wall on a dark mottled ground, with `noise` levels of noise per channel: +/// a photo's tones, in color or (`gray`) as their grays. +fn photo_scene(noise: f64, gray: bool) -> Raster { + Raster::from_fn(160, 128, |x, y| { + let ground = f64::from(20 + 10 * ((x / 16 + y / 16) % 3)); + let (dx, dy) = (f64::from(x) - 60.0, f64::from(y) - 64.0); + let c = if dx * dx + dy * dy < 1600.0 { + let shade = 0.25 + 0.75 * (1.0 - (dx + dy + 40.0) / 110.0).max(0.0); + [210.0 * shade + 20.0, 150.0 * shade + 20.0, 100.0 * shade + 20.0] + } else if x >= 110 { + let t = f64::from(y) / 127.0; + [200.0 - 90.0 * t, 180.0 - 40.0 * t, 70.0 + 50.0 * t] + } else { + [ground; 3] + }; + let p = [0, 1, 2].map(|k| (c[k] + noise * jitter(x, y, k as u32)).round().clamp(0.0, 255.0) as u8); + if gray { + let g = quantize::luma([p[0], p[1], p[2], 255]); + [g, g, g, 255] + } else { + [p[0], p[1], p[2], 255] + } + }) +} + +#[test] +fn a_gray_photo_is_continuous_tone_as_its_color_original_is() { + for noise in [4.0, 6.0] { + assert!(quantize::continuous_tone(&photo_scene(noise, false)), "color, noise {noise}"); + assert!(quantize::continuous_tone(&photo_scene(noise, true)), "gray, noise {noise}"); + } + // Three-color gray flat art stays flat art with up to 16 levels of uniform noise. (Heavier + // noise or JPEG artifacts can make gray flat art continuous tone.) + let clean = three_colour_art(0.0); + for noise in [0.0, 8.0, 16.0] { + let mut gray = three_colour_art(noise); + for p in gray.rgba.as_chunks_mut::<4>().0 { + let g = quantize::luma(*p); + p[..3].fill(g); + } + assert!(!quantize::continuous_tone(&gray), "gray flat art, noise {noise}"); + // The same noise on every channel, as a gray scan has it. + let scan = Raster::from_fn(clean.width, clean.height, |x, y| { + let i = ((y * clean.width + x) * 4) as usize; + let g = f64::from(quantize::luma([clean.rgba[i], clean.rgba[i + 1], clean.rgba[i + 2], 255])); + let v = (g + noise * jitter(x, y, 0)).round().clamp(0.0, 255.0) as u8; + [v, v, v, 255] + }); + assert!(!quantize::continuous_tone(&scan), "gray scan of flat art, noise {noise}"); + } + // Nine clean equal gray bands, more levels than eight colors can hold, stay flat art. + for step in [15, 30] { + let bands = Raster::from_fn(180, 60, |x, _| { + let v = (x / 20 * step) as u8; + [v, v, v, 255] + }); + assert!(!quantize::continuous_tone(&bands), "nine gray bands {step} levels apart"); + } +} + +#[test] +fn a_gray_photo_is_smoothed_before_it_is_traced() { + // As a color photo is. Traced with its noise, the noise mixes neighboring tones into one, + // leaving fewer colors than asked for, and steps the edges between them (more anchors). + for noise in [4.0, 6.0] { + let res = trace(&photo_scene(noise, true), &sixteen_colors()); + assert!(res.palette.len() >= 14, "noise {noise}: {} colors", res.palette.len()); + assert!(res.anchor_count() <= 1100, "noise {noise}: {} anchors", res.anchor_count()); + } +} + #[test] fn stacked_layers_go_by_lightness_and_leave_ignored_white_alone() { // Stripes: black, white, mid gray, light gray (the widest). diff --git a/crates/ui-egui/src/addons/cutting.rs b/crates/ui-egui/src/addons/cutting.rs index 82fd2d6e3b..1fbdcf88c2 100644 --- a/crates/ui-egui/src/addons/cutting.rs +++ b/crates/ui-egui/src/addons/cutting.rs @@ -1,6 +1,7 @@ //! Cutting Tools in the interface: its submenu (Object › Plug-ins › Cutting Tools). Stencil //! Bridges… opens the generic command dialog (`ui.paramDialog`, `dialogs::command`) on the options -//! of `addon.cutting.bridges`. +//! of `addon.cutting.bridges`. Weed Box and Lines… opens it on the options of `addon.cutting.weed`, +//! with a Preview, and runs the command on OK. A Spacing above 0 is sent instead of the Count. use serde_json::json; @@ -10,10 +11,21 @@ use crate::menus::Item; pub const BRIDGES: &str = "addon.cutting.bridges"; /// Stencil Bridges › Direction: what the command's `direction` takes, by name. pub const BRIDGE_DIRECTIONS: &[(&str, &str)] = &[("Vertical", "vertical"), ("Horizontal", "horizontal"), ("Shortest Crossing", "shortest")]; +/// Weed Box and Lines' command. +pub const WEED: &str = "addon.cutting.weed"; +/// Weed Box and Lines › Direction: what the command's `direction` takes, by name. +pub const WEED_DIRECTIONS: &[(&str, &str)] = &[("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both", "both")]; /// Object › Plug-ins › Cutting Tools. pub fn menu() -> Vec { - vec![Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true}))] + vec![ + Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true})), + Item::Cmd( + "Weed Box and Lines…", + WEED, + json!({"margin": 9, "box": true, "direction": "both", "count": 1, "spacing": 0, "gap": 0, "weight": 0.3, "swatch": "[Registration]"}), + ), + ] } #[cfg(test)] diff --git a/crates/ui-egui/src/addons/cutting/tests.rs b/crates/ui-egui/src/addons/cutting/tests.rs index d5620f717b..2e69eab904 100644 --- a/crates/ui-egui/src/addons/cutting/tests.rs +++ b/crates/ui-egui/src/addons/cutting/tests.rs @@ -52,3 +52,94 @@ fn stencil_bridges_opens_its_dialog_and_runs_on_ok() { assert_eq!(steps(&app), n + 1); assert!(app.ui.status.contains("no islands"), "{}", app.ui.status); } + +/// Object › Plug-ins › Cutting Tools › Weed Box and Lines… opens the command dialog on its +/// defaults with Preview off. With Preview on the canvas shows the weed box and lines while the +/// dialog is open; Cancel takes them away with no step of history, and OK keeps them as one step. +#[test] +fn weed_box_and_lines_previews_cancels_and_runs_on_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node_count(); + let (start, n, nodes) = (app.session.doc().unwrap().doc.clone(), steps(&app), count(&app)); + let open = |app: &mut VectorcraftApp| { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Cutting Tools"] && e.label == "Weed Box and Lines…").unwrap(); + assert!(item.enabled); + let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &cmd, p); + }; + + open(&mut app); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::WEED.to_string(), "Weed Box and Lines".to_string())); + assert_eq!((d.str("direction"), d.bool("box"), d.bool("preview")), ("both".to_string(), true, false)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Weed Box and Lines", "Margin", "Box", "Direction", "Both", "Count", "Spacing", "Gap", "Weight", "Swatch", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + // Every field label is translated. + crate::i18n::set_current(crate::i18n::Lang::from_code("de").expect("de registered")); + let german = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::i18n::set_current(crate::i18n::Lang::EN); + for s in ["Rand:", "Rahmen:", "Richtung:", "Farbfeld:"] { + assert!(german.contains(s), "{s} in {german}"); + } + assert_eq!(count(&app), nodes, "no preview without the checkbox"); + + // Preview on: the group with the box and four lines is on the canvas. + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true)); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert_eq!(count(&app), nodes + 6, "previewed: a group, the box and four lines"); + + // Cancel: as it was, with no step of history. + crate::dialogs::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, start, "Cancel takes the preview away"); + assert_eq!(steps(&app), n, "and adds no undo step"); + + // OK: one step. + open(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(steps(&app), n + 1, "one undo step"); + assert_eq!(count(&app), nodes + 6); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, start); + + // A Spacing above 0 is used instead of the Count: the 68 pt wide box in strips at most 20 pt + // wide takes three vertical lines, each cut in two by the rectangle. + app.run("select.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::set_field(d, "direction".into(), json!("vertical")); + crate::dialogs::set_field(d, "spacing".into(), json!(20)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(count(&app), nodes + 8, "a group, the box and six pieces of line"); + + // A Spacing below 0 is refused, not swapped for the Count. + app.run("edit.undo", json!({})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + let n = steps(&app); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "spacing".into(), json!(-5)); + let e = crate::dialogs::confirm(&mut app).unwrap_err().to_string(); + assert!(e.contains("`spacing` must be more than 0"), "{e}"); + assert_eq!(steps(&app), n, "no undo step"); + + // A Spacing with no value, or of 0 written with its unit, leaves the Count in use: a Count of 0 + // draws the box alone. + crate::dialogs::cancel(&mut app); + for spacing in [serde_json::Value::Null, json!("0 pt")] { + app.run("select.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::set_field(d, "spacing".into(), spacing.clone()); + crate::dialogs::set_field(d, "count".into(), json!(0)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(count(&app), nodes + 2, "{spacing}: a group and the box"); + app.run("edit.undo", json!({})).unwrap(); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3ce7143224..7865375b69 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -127,6 +127,7 @@ impl VectorcraftApp { updates_rt: Default::default(), frame_rate: Default::default(), canvas_rect: None, + canvas_ui_zoom: 1.0, control_rotate_press: None, hover_doc: None, titlebar_from_env: false, diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 16e8a14cc1..c418f1950c 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -150,6 +150,7 @@ impl VectorcraftApp { io::remember_active_artboard(self); let artboards = self.artboard_tool_state(); if let Some(r) = menus::run_ui_command(self, id, ¶ms) { + self.ui.recording_base = None; self.follow_artboard_tool(artboards); return r; } @@ -221,6 +222,15 @@ impl VectorcraftApp { if fileio::SaveMode::of(id).is_some() { io::remember_view(self); } + // While an action records, a command that the recording would keep as a step runs only + // when the step fits in the action. + let recorded = match panels::actions::before_recorded_command(self, id, ¶ms) { + Ok(recorded) => recorded, + Err(e) => { + self.ui.status = e.clone(); + return Err(e); + } + }; // A command that changes the edited text is an application edit (undo, a panel or menu // edit, a control request). Capture the bounded committed context around it so `Other` // dominates the frame even if an IME receipt follows. Selection/view commands that leave @@ -230,6 +240,7 @@ impl VectorcraftApp { let ime_before = crate::ime::stamp(&mut self.session, ime_view); let cursor_settings = (self.session.caps_lock, self.session.prefs.use_precise_cursors); let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + let r = panels::actions::after_recorded_command(self, recorded, r); if r.is_ok() && cursor_settings != (self.session.caps_lock, self.session.prefs.use_precise_cursors) { self.canvas.hover_cursor = None; } diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 0b87eb16b8..ce9aa6bf93 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,4 +1,4 @@ -use super::{GeometryStamp, InputGeneration, VectorcraftApp, canvas}; +use super::{GeometryStamp, InputGeneration, VectorcraftApp}; impl VectorcraftApp { /// The host iteration count: one increment per `App::logic`/`App::ui` iteration (or a @@ -9,8 +9,7 @@ impl VectorcraftApp { /// The canvas on screen in document coordinates (none before it is laid out). pub fn screen_frame(&self) -> Option { - let (rect, view) = (self.canvas_rect?, self.view()?); - let xf = canvas::Xf::new(rect, view); + let (rect, xf) = (self.canvas_rect?, self.canvas_xf()?); let px = |x: f32, y: f32| xf.delta_to_doc(egui::vec2(x, y)); Some(vectorcraft_tools::ScreenFrame { origin: xf.to_doc(rect.left_top()), diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index f88367cb60..0949b9290f 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -110,9 +110,16 @@ impl VectorcraftApp { self.views.get_mut(i) } + /// The mapping between the document and the canvas of the last frame, at the active view (none + /// before the canvas is laid out or without a document). + pub(crate) fn canvas_xf(&self) -> Option { + Some(crate::canvas::Xf::at(self.canvas_rect?, self.view()?, self.canvas_ui_zoom)) + } + pub fn view_info(&self) -> ViewInfo { ViewInfo { - zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), + // The tools measure their tolerances and widgets in the interface's points. + zoom: self.view().map_or(1.0, |v| v.screen_zoom(self.canvas_ui_zoom)), outline: self.outline_view(), smart_guides: self.ui.view.smart_guides, guides: self.ui.view.guides, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6e6806e075..202a0542a8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -98,8 +98,16 @@ pub struct Xf { } impl Xf { + /// The mapping of view `v` onto the canvas `rect`, in the points of an interface zoomed by + /// `ui_zoom` (Preferences › User Interface › UI Scaling): a document point covers the same + /// device pixels at every UI Scaling ([`View::screen_zoom`]). + pub fn at(rect: egui::Rect, v: &View, ui_zoom: f32) -> Self { + Self { rect, zoom: v.screen_zoom(ui_zoom), center: v.center, rot: v.rotation.to_radians() } + } + /// [`Self::at`] with the interface unscaled. + #[cfg(test)] pub fn new(rect: egui::Rect, v: &View) -> Self { - Self { rect, zoom: v.zoom, center: v.center, rot: v.rotation.to_radians() } + Self::at(rect, v, 1.0) } pub fn to_screen(&self, p: Point) -> Pos2 { let c = self.rect.center(); @@ -255,6 +263,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let bars = app.ui.screen_mode < 2; let (full, rect) = view_rects(app, outer, ui.ctx().pixels_per_point()); app.canvas_rect = Some(rect); + app.canvas_ui_zoom = ui.ctx().zoom_factor(); let fitted = app.view().is_some_and(|v| v.fitted); // A document's opening view is fitted to the canvas of the first frame that shows it, and the // window's layout can still move the canvas's edges over the next frames. Until the view is @@ -276,7 +285,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let resp = ui.interact(rect, egui::Id::unique("canvas"), Sense::click_and_drag()); canvas_input(app, ui, &resp, rect); let v = *app.view().unwrap_or(&View::default()); - let xf = Xf::new(rect, &v); + let xf = Xf::at(rect, &v, app.canvas_ui_zoom); // While the view pans, zooms or turns under the pointer, the art under it isn't hit-tested. let wait = hover_wait(&mut app.canvas.view_moved, ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation }, ui.input(|i| i.time)); if let Some(secs) = wait { @@ -313,12 +322,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ); // View › Pixel Preview: the art as it rasterizes, one pixel per point, while a document pixel // is bigger than a screen pixel (below that the screen render already shows it). - let pixel = (app.ui.view.pixel_preview && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); + let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); // File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show // their pixels as they rasterize, hard-edged. let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); - let key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images, plugins)); + // The frame renders the view at its zoom in the interface's points, as `xf` maps it. + let drawn = v.on_screen(app.canvas_ui_zoom); + let key = cache_key(app, ui, st, &t, &drawn, (w, h, ppp), (pixel, smooth_images, plugins)); // Placed documents' bitmaps are being made: draw again when they are ready. if vectorcraft_render::placed_document::busy() { ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); @@ -348,7 +359,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { !waiting }); if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) { - let (w, h, view) = frame_view(pixel, (w, h), &v, ppp); + let (w, h, view) = frame_view(pixel, (w, h), &drawn, ppp); let opts = frame_options(app, &key, mask_view, smooth_images); draw_frame(app, ui, Frame { key: &key, doc: &doc, effects: &effects, size: (w, h), view, opts, pixel }); } @@ -446,7 +457,7 @@ fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: // wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view // there. A dialog, floating panel or menu over the canvas hides it (Object Highlighting // showed through them: #4518), unless a drag that began on the canvas goes on there. - let moved = Xf::new(rect, app.view().unwrap_or(&View::default())); + let moved = Xf::at(rect, app.view().unwrap_or(&View::default()), app.canvas_ui_zoom); let dragging = ui.data(|d| d.get_temp::(drag_id()).is_some()); app.hover_doc = ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p) && (dragging || drag::on_canvas(ui, resp, *p))).map(|p| moved.to_doc(p)); @@ -1208,3 +1219,5 @@ fn selection_visuals( #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_ui_scaling; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 43e5df0bcd..5f06eb980d 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -14,7 +14,7 @@ use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape, pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect); let v = *app.view().unwrap_or(&View::default()); - let xf = Xf::new(rect, &v); + let xf = Xf::at(rect, &v, app.canvas_ui_zoom); // A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`), // so a tap whose press and lift come in one frame has no hover position: it presses (and // double-taps) where it was seen last (#491). @@ -227,10 +227,11 @@ fn wheel_view( // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The // tool's events below take their document points from the view this moves to. if resp.hovered() { + let ui_zoom = app.canvas_ui_zoom; if (factor - 1.0).abs() > 1e-6 && let (Some(p), Some(vm)) = (hover, app.view_mut()) { - zoom_about(vm, rect, p, vm.zoom * factor); + zoom_about(vm, rect, p, vm.zoom * factor, ui_zoom); } if scroll != egui::Vec2::ZERO { scroll_view(app, ui, scroll); @@ -239,7 +240,7 @@ fn wheel_view( && let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view) && let Some(vm) = app.view_mut() { - rotate_about(vm, rect, p, deg); + rotate_about(vm, rect, p, deg, ui_zoom); } } (pointer, m, space, hover) @@ -328,12 +329,13 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) // Where the pointer was in the frame before: a zoom marquee moved with Space follows it. let before: Option = ui.data(|dd| dd.get_temp(drag_pos_id())); ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p)); + let ui_zoom = app.canvas_ui_zoom; match d { Drag::Pan { start, center, .. } => { let ppp = ui.ctx().pixels_per_point(); if let Some(vm) = app.view_mut() { vm.center = center; - pan_pixels(vm, ppp, whole_pixels(p - start, ppp)); + pan_pixels(vm, (ppp, ui_zoom), whole_pixels(p - start, ppp)); } } Drag::RotateView { start_angle, start_rot, snaps } => { @@ -360,7 +362,7 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) if exponent != 0.0 && let Some(vm) = app.view_mut() { - zoom_about(vm, rect, start, vm.zoom * exponent.exp()); + zoom_about(vm, rect, start, vm.zoom * exponent.exp(), ui_zoom); } ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved })); } @@ -446,6 +448,7 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po }); // Backtick as it was at the button-up: a key pressed or released later in the frame does not count. let backquote = backquote_at_release(ui, egui::PointerButton::Primary, f.backquote_before); + let ui_zoom = app.canvas_ui_zoom; match d { Drag::ZoomBox { start } => { let start = zoom_marquee_start(ui, start, before, p, space); @@ -456,16 +459,16 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po // The marquee's center and its size on screen set the view, at any rotation. vm.center = xf.to_doc(r.center()); let fill = (rect.width() / r.width()).min(rect.height() / r.height()); - vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0); + vm.zoom = (xf.zoom * f64::from(ui_zoom) * f64::from(fill)).clamp(0.0313, 640.0); } else { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt), ui_zoom); } } } // A click (not held, not dragged) steps the zoom as with the marquee. Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => { if let Some(vm) = app.view_mut() { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt), ui_zoom); } } Drag::ZoomScrub { .. } => {} diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 88c573c5e4..9551c45954 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -110,36 +110,44 @@ pub(super) fn mark_options(app: &VectorcraftApp, curve_points_from_tool: bool) - pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool, marks: &mut SelectionMarks) { // The Selection tool's bounding box (rotated with the objects after a rotation), also shown by // the shape tools that keep a shape just drawn ready to adjust while they draw none (#5477). It - // hides while a drag moves or resizes the selection, so only the art is seen going with the - // pointer (#712). A handle resize keeps it at the changing bounds (#3526). - // With Real-time Drawing off the art waits for the release of a move drag, and the box moves - // with the pointer instead. + // hides while a drag moves, rotates or resizes the selection, so only the art is seen going with + // the pointer (#712). A handle resize keeps it at the changing bounds (#3526). With General › + // Show Bounding Box While Moving and Drawing on, a move draws it where the drag has moved the + // selection, and a shape tool draws it around the shape being drawn. + // With Real-time Drawing off the art waits for the release of a move drag. With the preference + // off, the box of a Selection tool move then moves with the pointer instead. let resizing = app.session.tool_resizing(); - let moved = app.session.deferred_move(); + let move_box = app.session.move_drag_box(); + let drawing_boxed = app.session.draws_new_shape_box(); + let follows = resizing || move_box.is_some() || drawing_boxed; let shape_tool = vectorcraft_tools::shape::shows_shape_widgets(app.session.tool_id()); - // A shape tool drawing a shape shows none of this; adjusting a widget, it shows the widgets. + // A shape tool drawing a shape shows none of its widgets; adjusting a widget, it shows the widgets. let drawing = shape_tool && app.session.tool_busy() && !app.session.tool_transforming(); // Cmd held lends the Selection tool to a press (Direct Selection's included, #3901): its box // shows while the key is down, where its handles take that press. let held = super::drag::mods(p.ctx().input(|i| i.modifiers), false); let lends_selection = !app.session.tool_busy() && app.session.lent_tool_id(&held) == Some("selection"); - let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && (!app.session.tool_busy() || resizing))) - && (resizing || !app.session.tool_transforming() || moved.is_some()) - && app.ui.view.bounding_box - && app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty()); - // With Real-time Drawing off the document stays at the drag's snapshot, so a resize measures the - // box from the pending preview; otherwise each step's revision measures it once (`selection_box`). - let bbox = if !show_box { - None - } else if let Some(b) = resizing.then(|| app.session.deferred_transform_box()).flatten() { - Some(b) + let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && (!app.session.tool_busy() || follows))) + && (follows || !app.session.tool_transforming()) + && app.ui.view.bounding_box; + let selected_whole = app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty()); + // With Real-time Drawing off the document stays at the drag's snapshot, so a resize or a shape + // being drawn measures the box from the pending preview. A shape being drawn shows no box until + // a preview has made it (never the box of the selection from before the drag, which a failed + // preview also leaves). A move offsets the box measured at the drag's start. Otherwise each + // step's revision measures it once (`selection_box`). + let deferred = if show_box && (resizing || drawing_boxed) { app.session.deferred_transform_box() } else { None }; + let (bbox, deferred_color) = if !show_box { + (None, None) + } else if let Some((b, color)) = deferred { + (Some(b), Some(color)) + } else if !selected_whole || (drawing_boxed && app.session.interaction_unapplied()) { + (None, None) + } else if move_box.is_some() { + (move_box, None) } else { - app.selection_box() + (app.selection_box(), None) }; - let bbox = bbox.map(|b| match moved { - Some(d) => vectorcraft_doc::OrientedBox { rect: b.rect + b.to_local(d.to_point()).to_vec2(), ..b }, - None => b, - }); let st = app.session.active(); let show_marks = !crate::dialogs::halftone::previewing(app); let big = show_marks && st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id)))); @@ -209,8 +217,11 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: } } // Bounding box with handles (Selection tool). - if let Some(b) = bbox { - let color = c32(st.doc.layer_color(st.selection.objects[0])); + // The layer color of the box's first object: with Real-time Drawing off, the shape being drawn + // is only in the pending preview, which gives its color. + let box_color = deferred_color.or_else(|| st.selection.objects.first().map(|id| st.doc.layer_color(*id))); + if let Some((b, color)) = bbox.zip(box_color) { + let color = c32(color); p.add(Shape::closed_line(b.corners().iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.0, color))); for h in vectorcraft_tools::bbox::Handle::ALL { let c = xf.to_screen(b.to_doc() * h.pos(b.rect)); @@ -218,8 +229,9 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: p.rect_filled(hr, 0.0, Color32::WHITE); p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); } - // The type widget beside it: hollow on point type, filled on area type. - if let Some(w) = vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &b, xf.zoom) { + // The type widget beside it: hollow on point type, filled on area type. A shape being drawn + // has none (with Real-time Drawing off, the selection is still the one before the drag). + if !drawing_boxed && let Some(w) = vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &b, xf.zoom) { let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::typewidget::RADIUS_PX); p.circle_filled(c, r, if w.area { color } else { Color32::WHITE }); p.circle_stroke(c, r, Stroke::new(1.0, color)); diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index c8dc007538..fb305d29dd 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -263,11 +263,11 @@ fn refit_resized(app: &mut VectorcraftApp, areas: &[(Win, egui::Rect)]) -> Vec 1.0).then(|| pixel_region(&xf)).flatten(); + let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); - let mut key = cache_key(app, ui, st, &t, &v, (pw, ph, ppp), (pixel, smooth_images, plugins)); + let drawn = v.on_screen(ui_zoom); + let mut key = cache_key(app, ui, st, &t, &drawn, (pw, ph, ppp), (pixel, smooth_images, plugins)); // The current document's drag and Image Trace hold are not this document's, and this view has // its own Outline or Preview. (key.dragging, key.trace_source, key.outline) = (false, None, v.outline); let opts = frame_options(app, &key, st.shown_mask(), smooth_images); - let (fw, fh, view) = frame_view(pixel, (pw, ph), &v, ppp); + let (fw, fh, view) = frame_view(pixel, (pw, ph), &drawn, ppp); let doc = st.doc.clone(); app.canvas.panes.draw(ui.ctx(), &painter, &xf, slot, (key, doc, ((fw, fh), view), opts)); let Some(st) = app.session.documents().get(w.0) else { return false }; diff --git a/crates/ui-egui/src/canvas/panes/tests.rs b/crates/ui-egui/src/canvas/panes/tests.rs index 91a0ca5737..9dc008a896 100644 --- a/crates/ui-egui/src/canvas/panes/tests.rs +++ b/crates/ui-egui/src/canvas/panes/tests.rs @@ -105,7 +105,7 @@ fn tile_shows_a_pane_for_each_document_fitted_to_it() { for (i, r) in docs.iter().zip(&rects) { let v = app.views[*i]; let shown = if *i == 2 { canvas } else { view_rects(&app, *r, ctx.pixels_per_point()).1 }; - assert!(v.fitted && (v.zoom - fit_zoom(shown, artboard, 0.0)).abs() < 1e-9, "document {i} fits its pane: {v:?}"); + assert!(v.fitted && (v.zoom - fit_zoom(shown, artboard, 0.0, 1.0)).abs() < 1e-9, "document {i} fits its pane: {v:?}"); assert_eq!(v.center, artboard.center()); } assert_eq!(app.canvas.panes.shown.len(), 2, "the other two documents are rendered for their panes"); @@ -115,6 +115,29 @@ fn tile_shows_a_pane_for_each_document_fitted_to_it() { assert_eq!(active, [false, false, true]); } +/// With the interface zoomed by Preferences › User Interface › UI Scaling, each tiled document +/// fits its pane with 30 interface points to spare: its zoom is the zoom that fits it in an +/// unscaled interface times the UI Scaling. +#[test] +fn tiled_documents_fit_their_panes_at_every_ui_scaling() { + for ui_zoom in [0.75_f32, 1.5, 2.0] { + let mut app = app_with(3); + let ctx = egui::Context::default(); + ctx.set_zoom_factor(ui_zoom); + let size = vec2(1200.0, 800.0) / ui_zoom; + frame_in(&mut app, &ctx, vec![], size); + app.run("window.arrange.tile", json!({})).unwrap(); + frame_in(&mut app, &ctx, vec![], size); + let (docs, rects) = panes(&app); + for (i, r) in docs.iter().zip(&rects).filter(|(i, _)| **i != 2) { + let shown = view_rects(&app, *r, ctx.pixels_per_point()).1; + let unscaled = ((f64::from(shown.width()) - 60.0) / 400.0).min((f64::from(shown.height()) - 60.0) / 300.0); + let v = app.views[*i]; + assert!(v.fitted && (v.zoom - unscaled * f64::from(ui_zoom)).abs() < 1e-9, "UI Scaling {ui_zoom}×: document {i} fits its pane: {v:?}"); + } + } +} + #[test] fn a_press_in_another_pane_makes_its_document_current_without_drawing() { let mut app = app_with(3); @@ -281,7 +304,7 @@ fn opening_a_document_while_tiled_fits_each_document_to_its_new_pane() { let pane = panes(&app).1[0]; let rect = view_rects(&app, pane, ctx.pixels_per_point()).1; let v = app.views[0]; - assert!((v.zoom - fit_zoom(rect, Rect::new(0.0, 0.0, 400.0, 300.0), 0.0)).abs() < 1e-9, "fitted to its new pane: {v:?}"); + assert!((v.zoom - fit_zoom(rect, Rect::new(0.0, 0.0, 400.0, 300.0), 0.0, 1.0)).abs() < 1e-9, "fitted to its new pane: {v:?}"); assert_ne!(v.zoom, side_by_side, "the pane changed size"); } @@ -315,7 +338,7 @@ fn a_resized_window_fits_the_tiled_documents_again_unless_one_was_zoomed() { let (_, rects) = panes(&app); for i in [0, 2] { let area = if i == 2 { app.canvas_rect.unwrap() } else { view_rects(&app, rects[i], ctx.pixels_per_point()).1 }; - assert!((app.views[i].zoom - fit_zoom(area, artboard, 0.0)).abs() < 1e-9, "document {i} fits its smaller pane: {:?}", app.views[i]); + assert!((app.views[i].zoom - fit_zoom(area, artboard, 0.0, 1.0)).abs() < 1e-9, "document {i} fits its smaller pane: {:?}", app.views[i]); } assert_eq!(app.views[1].zoom, zoomed.zoom, "the zoomed document keeps its zoom"); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 069b0df087..66f3f1423c 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1360,13 +1360,15 @@ fn a_guide_dragged_out_of_a_ruler_shows_while_dragged() { /// With Preferences › Performance › Real-time Drawing and Editing off, the bounding box moves /// with a Selection-tool move drag while the object stays put until the release. With it on, the -/// art moves and the box hides during the drag (#712). +/// art moves and the box hides during the drag (#712), unless General › Show Bounding Box While +/// Moving and Drawing is on, which keeps the box with the art. #[test] fn real_time_drawing_off_moves_the_bounding_box_with_a_move_drag() { - for real_time in [true, false] { + for (real_time, while_moving) in [(true, false), (false, false), (true, true), (false, true)] { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); app.session.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": while_moving})).unwrap(); app.ui.task_bar = false; app.ui.view.rulers = false; app.ui.view.smart_guides = false; @@ -1389,15 +1391,19 @@ fn real_time_drawing_off_moves_the_bounding_box_with_a_move_drag() { app.session.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 130.0), view).unwrap(); app.session.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 230.0), view).unwrap(); let during = handles(&mut app); - if real_time { - assert!(during.is_empty(), "the box hides while the art moves: {during:?}"); - assert_eq!(left(&app), start + 100.0, "the art moves with the pointer"); + let what = format!("real-time drawing {real_time}, box while moving {while_moving}"); + if real_time && !while_moving { + assert!(during.is_empty(), "{what}: the box hides while the art moves: {during:?}"); } else { - assert_eq!(during.len(), 8, "the box shows during the drag"); + assert_eq!(during.len(), 8, "{what}: the box shows during the drag"); let corner = xf.to_screen(Point::new(200.0, 200.0)); - assert!(during.iter().any(|c| c.distance(corner) < 0.5), "the box's corner is at the dragged position: {during:?}"); - assert!(!during.iter().any(|c| c.distance(xf.to_screen(Point::new(100.0, 100.0))) < 0.5), "not at the start"); - assert_eq!(left(&app), start, "the object waits for the release"); + assert!(during.iter().any(|c| c.distance(corner) < 0.5), "{what}: the box's corner is at the dragged position: {during:?}"); + assert!(!during.iter().any(|c| c.distance(xf.to_screen(Point::new(100.0, 100.0))) < 0.5), "{what}: not at the start"); + } + if real_time { + assert_eq!(left(&app), start + 100.0, "{what}: the art moves with the pointer"); + } else { + assert_eq!(left(&app), start, "{what}: the object waits for the release"); } app.session.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 230.0), view).unwrap(); assert_eq!(left(&app), start + 100.0, "real-time drawing {real_time}: moved on release"); @@ -4144,11 +4150,11 @@ fn scrolls_and_pans_move_the_view_by_whole_device_pixels() { fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() { let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0, outline: false }; let mut v = view; - let rest = scroll_by(&mut v, 1, 1.0, vec2(0.4, -0.3), ScrollRest::default()); + let rest = scroll_by(&mut v, 1, (1.0, 1.0), vec2(0.4, -0.3), ScrollRest::default()); assert_eq!(v.center, view.center, "less than half a pixel does not move the view"); assert!((rest.x - 0.4).abs() < 1e-6 && (rest.y + 0.3).abs() < 1e-6); let mut again = v; - scroll_by(&mut again, 1, 1.0, vec2(0.4, -0.3), rest); + scroll_by(&mut again, 1, (1.0, 1.0), vec2(0.4, -0.3), rest); assert_eq!(again.center, Point::new(99.0, 101.0), "the second scroll adds the first's part"); let changed = [ ("zoomed", View { zoom: 2.0, ..v }, 1, 1.0), @@ -4159,7 +4165,7 @@ fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() { ]; for (name, before, doc, ppp) in changed { let mut after = before; - let left = scroll_by(&mut after, doc, ppp, vec2(0.4 / ppp, -0.3 / ppp), rest); + let left = scroll_by(&mut after, doc, (ppp, 1.0), vec2(0.4 / ppp, -0.3 / ppp), rest); assert_eq!(after.center, before.center, "{name}: the first scroll's part is dropped"); assert!((left.x - 0.4).abs() < 1e-6 && (left.y + 0.3).abs() < 1e-6, "{name}: {left:?}"); } @@ -4261,16 +4267,23 @@ fn hidden_corner_widgets_do_not_answer_the_pointer() { /// One headless canvas frame → the bounding box drawn: its outline's corners on screen (the /// closed four-point line in the layer color; `None` when it is hidden) and the number of its -/// handles (white 6 px squares). +/// handles (white 6 px squares, to 0.01 px: at a fractional screen position a square's corners +/// round to a size a little off 6). fn bounding_box_drawn(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Option>, usize) { let st = app.session.active().unwrap(); - let layer = egui::epaint::ColorMode::Solid(c32(st.doc.layer_color(st.selection.objects[0]))); + let layer = st.doc.layer_color(st.selection.objects[0]); + bounding_box_drawn_in(app, ctx, layer) +} + +/// [`bounding_box_drawn`] for a box in the layer color `layer`, whatever is selected. +fn bounding_box_drawn_in(app: &mut VectorcraftApp, ctx: &egui::Context, layer: [u8; 3]) -> (Option>, usize) { + let layer = egui::epaint::ColorMode::Solid(c32(layer)); let drawn = shapes(app, ctx); let outline = drawn.iter().find_map(|s| match s { Shape::Path(ps) if ps.closed && ps.points.len() == 4 && ps.stroke.color == layer => Some(ps.points.clone()), _ => None, }); - let handle = |s: &&Shape| matches!(s, Shape::Rect(r) if r.rect.size() == vec2(6.0, 6.0) && r.fill == Color32::WHITE); + let handle = |s: &&Shape| matches!(s, Shape::Rect(r) if (r.rect.size() - vec2(6.0, 6.0)).length() < 0.01 && r.fill == Color32::WHITE); (outline, drawn.iter().filter(handle).count()) } @@ -4316,8 +4329,242 @@ fn a_resize_drag_keeps_the_bounding_box_at_the_new_bounds() { } } +/// With General › Show Bounding Box While Moving and Drawing on, a move of the selection keeps the +/// bounding box and its handles drawn where the drag carries the selection, with Real-time Drawing +/// on and off: a Selection-tool move, an Alt-drag copy, and a shape tool's move by the center point. +/// The box follows each step of the drag and ends where the release leaves the selection. With View +/// › Hide Bounding Box, no box shows during the move. With the preference off, the box hides during +/// the move (#712), except for a Selection-tool move with Real-time Drawing off, where the art waits +/// for the release and the box moves instead. +#[test] +fn a_move_drag_keeps_the_bounding_box_where_the_drag_carries_the_selection() { + for (real_time, while_moving) in [(true, true), (false, true), (true, false), (false, false)] { + for (tool, alt) in [("selection", false), ("selection", true), ("rectangle", false)] { + let what = format!("{tool}, alt {alt}, real time {real_time}, box while moving {while_moving}"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.session.execute("attributes.set", &json!({"showCenter": true})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": while_moving})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool(tool); + let (outline, handles) = bounding_box_drawn(&mut app, &ctx); + let before = box_corners(&mut app); + assert!(outline.is_some_and(|o| same_corners(&o, &before)) && handles == 8, "{what}: the box at rest"); + let mods = Mods { alt, ..Mods::default() }; + let view = app.view_info(); + let at = |kind, x, y| PointerEvent::new(kind, x, y).with_mods(mods); + app.session.pointer(&at(PointerKind::Down, 150.0, 130.0), view).unwrap(); + app.session.pointer(&at(PointerKind::Drag, 170.0, 140.0), view).unwrap(); + assert!(app.session.tool_transforming() && !app.session.tool_resizing(), "{what}: the drag moves the selection"); + let (first, handles) = bounding_box_drawn(&mut app, &ctx); + if !while_moving && !(tool == "selection" && !real_time) { + assert_eq!((first, handles), (None, 0), "{what}: the box hides during the move"); + app.session.pointer(&at(PointerKind::Up, 190.0, 165.0), view).unwrap(); + assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "{what}: back after the move"); + continue; + } + assert_eq!(handles, 8, "{what}: the handles show during the move"); + app.session.pointer(&at(PointerKind::Drag, 190.0, 165.0), view).unwrap(); + let (during, handles) = bounding_box_drawn(&mut app, &ctx); + assert_eq!(handles, 8, "{what}"); + assert!( + first.as_ref().zip(during.as_ref()).is_some_and(|(a, b)| !same_corners(a, b)), + "{what}: the box follows the drag: {first:?}, {during:?}" + ); + app.ui.view.bounding_box = false; + assert_eq!(bounding_box_drawn(&mut app, &ctx), (None, 0), "{what}: View › Hide Bounding Box"); + app.ui.view.bounding_box = true; + app.session.pointer(&at(PointerKind::Up, 190.0, 165.0), view).unwrap(); + let after = box_corners(&mut app); + assert!(!same_corners(&after, &before), "{what}: the drag moved the rectangle"); + assert!(during.as_ref().is_some_and(|o| same_corners(o, &after)), "{what}: {during:?}, moved to {after:?}"); + } + } +} + +/// With General › Show Bounding Box While Moving and Drawing on, the Rectangle, Rounded Rectangle, +/// Ellipse, Polygon and Star tools draw the bounding box and its handles around the shape being +/// drawn, at each step of the drag, where the release leaves the new shape's box; with Real-time +/// Drawing on and off, and whether or not something was selected before (off, the document and the +/// selection stay as they were until the release). Line Segment, which shows no box, draws none, +/// and View › Hide Bounding Box hides it. With the preference off, no box shows while a shape is +/// drawn. +#[test] +fn a_shape_being_drawn_shows_its_bounding_box() { + for (real_time, while_drawing) in [(true, true), (false, true), (true, false), (false, false)] { + for selected in [false, true] { + for tool in ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment"] { + let what = format!("{tool}, real time {real_time}, selected before {selected}, box while drawing {while_drawing}"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let old = + app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap(); + let layer = app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(old)); + if !selected { + app.session.execute("select.none", &json!({})).unwrap(); + } + app.session.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": while_drawing})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool(tool); + frame(&mut app, &ctx, vec![]); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 120.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 140.0), view).unwrap(); + assert_eq!(app.session.draws_new_shape_box(), while_drawing && tool != "lineSegment", "{what}"); + let (first, handles) = bounding_box_drawn_in(&mut app, &ctx, layer); + if tool == "lineSegment" || !while_drawing { + assert_eq!((first, handles), (None, 0), "{what}: no box"); + continue; + } + assert_eq!(handles, 8, "{what}: the handles show while the shape is drawn"); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 210.0, 170.0), view).unwrap(); + let (during, handles) = bounding_box_drawn_in(&mut app, &ctx, layer); + assert_eq!(handles, 8, "{what}"); + assert!( + first.as_ref().zip(during.as_ref()).is_some_and(|(a, b)| !same_corners(a, b)), + "{what}: the box follows the drag: {first:?}, {during:?}" + ); + app.ui.view.bounding_box = false; + assert_eq!(bounding_box_drawn_in(&mut app, &ctx, layer), (None, 0), "{what}: View › Hide Bounding Box"); + app.ui.view.bounding_box = true; + app.session.pointer(&PointerEvent::new(PointerKind::Up, 210.0, 170.0), view).unwrap(); + let selection = app.session.active().unwrap().selection.objects.clone(); + assert!(selection.len() == 1 && selection[0] != vectorcraft_doc::NodeId(old), "{what}: the new shape is selected"); + let after = box_corners(&mut app); + assert!(during.as_ref().is_some_and(|o| same_corners(o, &after)), "{what}: {during:?}, drawn to {after:?}"); + } + } + } +} + +/// With General › Show Bounding Box While Moving and Drawing on, a failed preview of a shape being +/// drawn (with Real-time Drawing on it leaves the document and the selection as they were before +/// the drag) shows no box rather than the box of the selection from before the drag. With +/// Real-time Drawing on and off. +#[test] +fn a_failed_preview_of_a_shape_being_drawn_shows_no_bounding_box() { + for real_time in [true, false] { + let what = format!("real time {real_time}"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let old = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap(); + let layer = app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(old)); + app.session.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": true})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("rectangle"); + frame(&mut app, &ctx, vec![]); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 120.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 140.0), view).unwrap(); + assert_eq!(bounding_box_drawn_in(&mut app, &ctx, layer).1, 8, "{what}: the box of the shape being drawn"); + assert!(app.session.preview("shape.rectangle", &json!({"x": "left"})).is_err(), "{what}: the preview fails"); + assert!(app.session.draws_new_shape_box(), "{what}: the shape is still being drawn"); + let selection = app.session.active().unwrap().selection.objects.clone(); + assert_eq!(selection, vec![vectorcraft_doc::NodeId(old)], "{what}: the selection from before the drag"); + assert_eq!(bounding_box_drawn_in(&mut app, &ctx, layer), (None, 0), "{what}: no box, and not the old selection's"); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 210.0, 170.0), view).unwrap(); + assert_eq!(bounding_box_drawn_in(&mut app, &ctx, layer).1, 8, "{what}: the next step's preview shows the box again"); + } +} + +/// With General › Show Bounding Box While Moving and Drawing on, the box of a shape being drawn is +/// in the color of the layer the shape goes on, also when the selection from before the drag is on +/// another layer: with Real-time Drawing off, the shape is only in the pending preview. +#[test] +fn a_shape_being_drawn_has_its_box_in_its_own_layers_color() { + for real_time in [true, false] { + let what = format!("real time {real_time}"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let old = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap(); + let old_layer = app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(old)); + let new_layer = vectorcraft_doc::NodeId(app.session.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); + let new_color = app.session.active().unwrap().doc.layer_color(new_layer); + assert_ne!(old_layer, new_color, "{what}: the two layers have different colors"); + assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(old)], "{what}: the old rectangle stays selected"); + app.session.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": true})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("rectangle"); + frame(&mut app, &ctx, vec![]); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 120.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 140.0), view).unwrap(); + let (outline, handles) = bounding_box_drawn_in(&mut app, &ctx, new_color); + assert!(outline.is_some() && handles == 8, "{what}: the box in the new layer's color"); + assert_eq!(bounding_box_drawn_in(&mut app, &ctx, old_layer).0, None, "{what}: not in the old selection's layer color"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 140.0), view).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer_containing(st.selection.objects[0]), Some(new_layer), "{what}: the shape went on the new layer"); + } +} + +/// With General › Show Bounding Box While Moving and Drawing on and Real-time Drawing off, a shape +/// drawn while point type is selected shows its box but not the type widget, which belongs to the +/// type selected before the drag. +#[test] +fn a_shape_being_drawn_has_no_type_widget() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 40, "y": 40, "text": "Type"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let layer = app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)); + app.session.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": false})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": true})).unwrap(); + let ctx = egui::Context::default(); + let widgets = |app: &mut VectorcraftApp| { + let r = vectorcraft_tools::typewidget::RADIUS_PX; + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == r && c.fill != Color32::TRANSPARENT)).count() + }; + frame(&mut app, &ctx, vec![]); + app.select_tool("selection"); + assert_eq!(widgets(&mut app), 1, "the type widget of the selected type"); + app.select_tool("rectangle"); + frame(&mut app, &ctx, vec![]); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 120.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 210.0, 170.0), view).unwrap(); + assert_eq!(bounding_box_drawn_in(&mut app, &ctx, layer).1, 8, "the box of the shape being drawn"); + assert_eq!(widgets(&mut app), 0, "no type widget beside it"); +} + +/// With General › Show Bounding Box While Moving and Drawing on, the Pencil, the Pen and the +/// Paintbrush draw no bounding box while they draw, as they show none afterwards; the selection's +/// box does not show either. +#[test] +fn freehand_and_pen_drawing_show_no_bounding_box() { + for tool in ["pencil", "pen", "paintbrush"] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": true})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool(tool); + frame(&mut app, &ctx, vec![]); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 120.0), view).unwrap(); + for (x, y) in [(170.0, 130.0), (190.0, 150.0), (210.0, 170.0)] { + app.session.pointer(&PointerEvent::new(PointerKind::Drag, x, y), view).unwrap(); + } + assert!(!app.session.draws_new_shape_box(), "{tool}"); + let layer = app.session.active().unwrap().doc.layer_color(app.session.active().unwrap().doc.default_layer().unwrap()); + assert_eq!(bounding_box_drawn_in(&mut app, &ctx, layer), (None, 0), "{tool}: no box while it draws"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 210.0, 170.0), view).unwrap(); + } +} + /// A move, a rotation and a corner-radius drag keep hiding the bounding box and its handles until -/// the release (#712, #1229). +/// the release (#712, #1229). With General › Show Bounding Box While Moving and Drawing on, a move +/// shows them, and a rotation and a corner-radius drag still hide them. #[test] fn a_move_rotate_or_corner_radius_drag_hides_the_bounding_box() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -4347,6 +4594,18 @@ fn a_move_rotate_or_corner_radius_drag_hides_the_bounding_box() { }; assert_eq!(drag(&mut app, corner, corner + vectorcraft_geom::Vec2::new(10.0, 10.0)), (None, 0), "a corner-radius drag"); assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the corner-radius drag"); + // General › Show Bounding Box While Moving and Drawing keeps the box during a move only. The + // corner-radius drag and the rotation are undone first: the rectangle is at 110, 110 to 210, 210. + app.session.execute("edit.undo", &json!({})).unwrap(); + app.session.execute("edit.undo", &json!({})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": true})).unwrap(); + assert_eq!(drag(&mut app, Point::new(150.0, 150.0), Point::new(160.0, 160.0)).1, 8, "a move with the preference on"); + assert_eq!(drag(&mut app, Point::new(220.0 + offset, 220.0 + offset), Point::new(270.0, 170.0)), (None, 0), "a rotation with the preference on"); + let corner = { + let st = app.session.active().unwrap(); + vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, app.view_info().zoom, false).unwrap().visible().next().unwrap() + }; + assert_eq!(drag(&mut app, corner, corner + vectorcraft_geom::Vec2::new(10.0, 10.0)), (None, 0), "a corner-radius drag with the preference on"); } /// View › Hide Edges hides the bounding box and the type widget beside it, and the Selection diff --git a/crates/ui-egui/src/canvas/tests_ui_scaling.rs b/crates/ui-egui/src/canvas/tests_ui_scaling.rs new file mode 100644 index 0000000000..d9e2a96edf --- /dev/null +++ b/crates/ui-egui/src/canvas/tests_ui_scaling.rs @@ -0,0 +1,214 @@ +//! Preferences › User Interface › UI Scaling zooms the interface and not the art: at a given zoom +//! a document point covers the same device pixels at every UI Scaling, and the pointer lands on the +//! art drawn under it. The Navigator's view box and tiled panes have their own tests +//! (`panels::navigator`, `canvas::panes`). + +use super::*; +use vectorcraft_engine::Session; + +/// The window, in points of the screen before UI Scaling. +const WINDOW: egui::Vec2 = vec2(1200.0, 900.0); + +/// One canvas frame in a `WINDOW` window at `native` device pixels a point, with the interface +/// zoomed by `ui_zoom`. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, (native, ui_zoom): (f32, f32), events: Vec) { + ctx.set_zoom_factor(ui_zoom); + let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, WINDOW / ui_zoom)), events, ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native); + let mut out = ctx.run_ui(input, |ui| show(app, ui)); + out.textures_delta.clear(); +} + +/// A 512 × 512 pt document shown at `native` device pixels a point and UI Scaling `ui_zoom`, with a +/// controlled render worker, and the receiver of the frames it is asked to render. +fn shown(native: f32, ui_zoom: f32) -> (VectorcraftApp, egui::Context, std::sync::mpsc::Receiver) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 512, "height": 512})).unwrap(); + app.canvas.worker_started = true; + let (worker, jobs, _done) = crate::render_worker::Worker::test_channel(); + app.canvas.worker = Some(worker); + // The first frames set the UI zoom and fit the artboard. + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + assert!((ctx.pixels_per_point() - native * ui_zoom).abs() < 1e-4, "{} device pixels a point", ctx.pixels_per_point()); + (app, ctx, jobs) +} + +/// The view matrix of the last frame the canvas asked the worker to render. +fn rendered(jobs: &std::sync::mpsc::Receiver) -> Affine { + jobs.try_iter().last().expect("a frame was rendered").view +} + +/// The device pixels between two document points in the frame `view` renders. +fn device_length(view: Affine, a: Point, b: Point) -> f64 { + (view * b - view * a).hypot() +} + +/// At 100% a 128 pt edge covers 128 device pixels a point of the screen, whatever the UI Scaling, +/// in the rendered frame and in the canvas's pointer mapping; a fitted artboard draws at the zoom +/// the view reports and fits the canvas. +#[test] +fn a_document_point_covers_the_same_device_pixels_at_every_ui_scaling() { + let (a, b) = (Point::new(100.0, 100.0), Point::new(228.0, 100.0)); + for native in [1.0_f32, 2.0] { + for ui_zoom in [0.75_f32, 1.0, 1.5, 2.0] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, jobs) = shown(native, ui_zoom); + let device = |zoom: f64| 128.0 * zoom * f64::from(native); + + app.run("view.actualSize", json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + assert_eq!(app.view().unwrap().zoom, 1.0, "{at}: Actual Size is 100%"); + let drawn = device_length(rendered(&jobs), a, b); + assert!((drawn - device(1.0)).abs() < 1e-6, "{at}: the frame draws 128 pt over {drawn} device pixels"); + let xf = app.canvas_xf().unwrap(); + let pointed = f64::from((xf.to_screen(b) - xf.to_screen(a)).length() * ctx.pixels_per_point()); + assert!((pointed - device(1.0)).abs() < 1e-3, "{at}: the pointer maps 128 pt to {pointed} device pixels"); + + app.run("view.fitArtboard", json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let zoom = app.view().unwrap().zoom; + let view = rendered(&jobs); + let drawn = device_length(view, a, b); + assert!((drawn - device(zoom)).abs() < 1e-6, "{at}: at {zoom} the frame draws 128 pt over {drawn} device pixels"); + // The artboard fits the canvas with 30 interface points to spare on its closer sides. + let rect = app.canvas_rect.unwrap(); + let ppp = f64::from(ctx.pixels_per_point()); + let (lo, hi) = (view * Point::new(0.0, 0.0), view * Point::new(512.0, 512.0)); + let (w, h) = (f64::from(rect.width()) * ppp, f64::from(rect.height()) * ppp); + let spare = [lo.x, lo.y, w - hi.x, h - hi.y].into_iter().fold(f64::INFINITY, f64::min); + assert!((spare - 30.0 * ppp).abs() < 1.0, "{at}: {spare} device pixels to spare in {w} × {h}"); + } + } +} + +/// View › Pixel Preview shows document pixels while one is bigger than a device pixel: at 80% on +/// a screen of one device pixel a point it does not, whatever the UI Scaling. +#[test] +fn pixel_preview_compares_a_document_pixel_with_a_device_pixel() { + for (native, zoom, pixels) in [(1.0_f32, 0.8, false), (1.0, 1.25, true), (2.0, 0.8, true)] { + for ui_zoom in [0.75_f32, 1.0, 1.5, 2.0] { + let (mut app, ctx, jobs) = shown(native, ui_zoom); + app.ui.view.pixel_preview = true; + app.run("view.setZoom", json!({"zoom": zoom * 100.0})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let job = jobs.try_iter().last().expect("a frame was rendered"); + assert_eq!(job.key.pixel.is_some(), pixels, "{native} device pixels a point at {zoom}, UI Scaling {ui_zoom}×"); + } + } +} + +/// At 100% a 20 pt square 100 pt to the right of the view's center is 100 device pixels a point +/// to the right of the canvas's center: a click there selects it, the frame draws it there, and the +/// tools measure their tolerances in the interface's points, whatever the UI Scaling. +#[test] +fn a_click_at_the_true_size_position_selects_the_object_drawn_there_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 1.5), (1.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, jobs) = shown(native, ui_zoom); + app.run("view.actualSize", json!({})).unwrap(); + let c = app.view().unwrap().center; + let square = Point::new(c.x + 100.0, c.y); + let id = + app.session.execute("shape.rectangle", &json!({"x": square.x - 10.0, "y": square.y - 10.0, "width": 20, "height": 20})).unwrap()["id"] + .as_u64() + .unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let rect = app.canvas_rect.unwrap(); + let ppp = ctx.pixels_per_point(); + let p = rect.center() + vec2(100.0 * native / ppp, 0.0); + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(true)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(false)]); + let selected = app.session.active().unwrap().selection.objects.clone(); + assert_eq!(selected, vec![vectorcraft_doc::NodeId(id)], "{at}: a click at {p:?}"); + let device = rendered(&jobs) * square; + let drawn = rect.min + vec2(device.x as f32, device.y as f32) / ppp; + assert!((drawn - p).length() * ppp < 1.0, "{at}: drawn at {drawn:?}, not {p:?}"); + assert!((app.view_info().zoom - 1.0 / f64::from(ui_zoom)).abs() < 1e-9, "{at}: the tools' zoom is {}", app.view_info().zoom); + } +} + +/// A pinch zooms about the pointer: the document point under the pointer stays under it, whatever +/// the UI Scaling. +#[test] +fn a_pinch_zooms_about_the_pointer_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + let rect = app.canvas_rect.unwrap(); + let p = rect.center() + vec2(150.0, -90.0) / ui_zoom; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p)]); + let (before, z0) = (app.canvas_xf().unwrap().to_doc(p), app.view().unwrap().zoom); + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p), egui::Event::Zoom(1.25)]); + for _ in 0..4 { + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + } + let z1 = app.view().unwrap().zoom; + assert!(z1 > z0 * 1.01, "{at}: the pinch zooms in, {z0} → {z1}"); + let after = app.canvas_xf().unwrap().to_doc(p); + let moved = (after - before).hypot() * app.view().unwrap().screen_zoom(ui_zoom) * f64::from(ctx.pixels_per_point()); + assert!(moved < 1e-3, "{at}: {before:?} under the pointer moved {moved} device pixels to {after:?}"); + } +} + +/// With Animated Zoom off, the Zoom tool zooms to the area dragged across: the zoom grows by the +/// times the marquee fits in the canvas, and the marquee's area then fills the canvas, whatever +/// the UI Scaling. +#[test] +fn the_zoom_tools_marquee_fills_the_canvas_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let rect = app.canvas_rect.unwrap(); + let (from, to) = (rect.center() - vec2(60.0, 20.0), rect.center() + vec2(40.0, 30.0)); + let xf = app.canvas_xf().unwrap(); + let (a, b, z0) = (xf.to_doc(from), xf.to_doc(to), app.view().unwrap().zoom); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(from, true)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(to)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(to, false)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let fill = f64::from((rect.width() / 100.0).min(rect.height() / 50.0)); + let z1 = app.view().unwrap().zoom; + assert!((z1 - z0 * fill).abs() < 1e-6, "{at}: {z0} → {z1}, not {}", z0 * fill); + let xf = app.canvas_xf().unwrap(); + let shown = xf.to_screen(b) - xf.to_screen(a); + let filled = (shown.x - rect.width()).abs().min((shown.y - rect.height()).abs()); + assert!(filled < 1e-2, "{at}: the marquee's area is {shown:?} in a {:?} canvas", rect.size()); + } +} + +/// A middle-button drag moves the art with the pointer: the document point under the press is +/// under the release, whatever the UI Scaling. +#[test] +fn a_pan_moves_the_art_with_the_pointer_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + let rect = app.canvas_rect.unwrap(); + let (from, to) = (rect.center(), rect.center() + vec2(60.0, -40.0)); + let pressed = app.canvas_xf().unwrap().to_doc(from); + let middle = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![middle(from, true)]); + for k in 1..=4 { + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))]); + } + frame(&mut app, &ctx, (native, ui_zoom), vec![middle(to, false)]); + let released = app.canvas_xf().unwrap().to_doc(to); + let ppp = f64::from(ctx.pixels_per_point()); + let zoom = app.view().unwrap().screen_zoom(ui_zoom); + assert!( + (released - pressed).hypot() * zoom * ppp < 1.0, + "{native} device pixels a point, UI Scaling {ui_zoom}×: {pressed:?} is now under the pointer as {released:?}" + ); + } +} diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 60cb6687b4..dae1060502 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -33,7 +33,7 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { let zoom = if how == "view.actualSize" { actual_size_zoom(app.session.prefs.display_print_size) } else { - fit_zoom(rect, target, app.view().map_or(0.0, |v| v.rotation)) + fit_zoom(rect, target, app.view().map_or(0.0, |v| v.rotation), app.canvas_ui_zoom) }; let Some(v) = app.view_mut() else { return }; v.center = target.center(); @@ -41,15 +41,15 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { v.zoom = zoom; } -/// The zoom at which `target`, turned `rotation` degrees with the view, fits in `rect` with a -/// 30-point margin on each side. -pub(super) fn fit_zoom(rect: egui::Rect, target: Rect, rotation: f64) -> f64 { +/// The zoom at which `target`, turned `rotation` degrees with the view, fits in `rect` (points of +/// an interface zoomed by `ui_zoom`) with a 30-point margin on each side. +pub(super) fn fit_zoom(rect: egui::Rect, target: Rect, rotation: f64, ui_zoom: f32) -> f64 { // The width and height of the box round the target turned with the view, at zoom 1. let (sn, cs) = rotation.to_radians().sin_cos(); let (w, h) = (target.width(), target.height()); let zx = (rect.width() as f64 - 60.0) / (w * cs.abs() + h * sn.abs()).max(1.0); let zy = (rect.height() as f64 - 60.0) / (w * sn.abs() + h * cs.abs()).max(1.0); - zx.min(zy).clamp(0.0313, 640.0) + (zx.min(zy) * f64::from(ui_zoom)).clamp(0.0313, 640.0) } /// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`). @@ -110,26 +110,27 @@ pub(crate) fn actual_size_zoom(display_print_size: bool) -> f64 { } /// Zoom view `vm` of the canvas `rect` to `zoom` (clamped to the zoom range), keeping the document -/// point under screen point `p` where it is. -pub(super) fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) { - let before = Xf::new(rect, vm).to_doc(p); +/// point under screen point `p` where it is, with the interface zoomed by `ui_zoom`. +pub(super) fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64, ui_zoom: f32) { + let before = Xf::at(rect, vm, ui_zoom).to_doc(p); vm.zoom = zoom.clamp(0.0313, 640.0); - vm.center += before - Xf::new(rect, vm).to_doc(p); + vm.center += before - Xf::at(rect, vm, ui_zoom).to_doc(p); } /// Turn view `vm` of the canvas `rect` by `degrees` clockwise, keeping the document point under -/// screen point `p` where it is. -pub(super) fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64) { - let before = Xf::new(rect, vm).to_doc(p); +/// screen point `p` where it is, with the interface zoomed by `ui_zoom`. +pub(super) fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64, ui_zoom: f32) { + let before = Xf::at(rect, vm, ui_zoom).to_doc(p); vm.rotation = vectorcraft_geom::normalize_deg(vm.rotation + degrees); - vm.center += before - Xf::new(rect, vm).to_doc(p); + vm.center += before - Xf::at(rect, vm, ui_zoom).to_doc(p); } /// Move view `vm`'s content by `(dx, dy)` device pixels along the screen's axes, at `ppp` device -/// pixels a point: the canvas's frame moves by exactly those pixels. -pub(super) fn pan_pixels(vm: &mut View, ppp: f32, (dx, dy): (f64, f64)) { +/// pixels a point of an interface zoomed by `ui_zoom`: the canvas's frame moves by exactly those +/// pixels. +pub(super) fn pan_pixels(vm: &mut View, (ppp, ui_zoom): (f32, f32), (dx, dy): (f64, f64)) { let (sn, cs) = vm.rotation.to_radians().sin_cos(); - let k = vm.zoom * f64::from(ppp); + let k = vm.screen_zoom(ui_zoom) * f64::from(ppp); vm.center -= vectorcraft_geom::Vec2::new((dx * cs + dy * sn) / k, (-dx * sn + dy * cs) / k); } @@ -155,20 +156,21 @@ pub(super) struct ScrollRest { /// of another kind, another document) or the device pixels a point did. pub(super) fn scroll_view(app: &mut VectorcraftApp, ui: &Ui, by: egui::Vec2) { let ppp = ui.ctx().pixels_per_point(); + let ui_zoom = app.canvas_ui_zoom; let Some(doc) = app.session.active().map(|st| st.uid) else { return }; let Some(vm) = app.view_mut() else { return }; let id = egui::Id::unique("canvas-scroll-rest"); - let rest = scroll_by(vm, doc, ppp, by, ui.data(|d| d.get_temp(id)).unwrap_or_default()); + let rest = scroll_by(vm, doc, (ppp, ui_zoom), by, ui.data(|d| d.get_temp(id)).unwrap_or_default()); ui.data_mut(|d| d.insert_temp(id, rest)); } -/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point, after the -/// scrolls that left `rest`: what this scroll leaves. -pub(super) fn scroll_by(vm: &mut View, doc: u64, ppp: f32, by: egui::Vec2, rest: ScrollRest) -> ScrollRest { +/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point of an interface +/// zoomed by `ui_zoom`, after the scrolls that left `rest`: what this scroll leaves. +pub(super) fn scroll_by(vm: &mut View, doc: u64, (ppp, ui_zoom): (f32, f32), by: egui::Vec2, rest: ScrollRest) -> ScrollRest { let (x, y) = if rest.after == Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) { (rest.x, rest.y) } else { (0.0, 0.0) }; let (x, y) = (x + f64::from(by.x) * f64::from(ppp), y + f64::from(by.y) * f64::from(ppp)); let (dx, dy) = (x.round(), y.round()); - pan_pixels(vm, ppp, (dx, dy)); + pan_pixels(vm, (ppp, ui_zoom), (dx, dy)); ScrollRest { x: x - dx, y: y - dy, after: Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) } } @@ -279,7 +281,8 @@ pub(super) fn scroll_tracks(rect: egui::Rect, outer: egui::Rect) -> [egui::Rect; pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui::Rect, t: &Tokens) { let content = app.content_bounds(); let Some(v) = app.view().copied() else { return }; - let xf = Xf::new(rect, &v); + let ui_zoom = app.canvas_ui_zoom; + let xf = Xf::at(rect, &v, ui_zoom); let painter = ui.painter(); painter.rect_filled(egui::Rect::from_min_max(outer.max - vec2(SCROLL_BAR, SCROLL_BAR), outer.max), 0.0, t.ruler); let hover = ui.input(|i| i.pointer.hover_pos()); @@ -346,7 +349,7 @@ pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, o && let Some(vm) = app.view_mut() { vm.center = c; - pan_pixels(vm, ppp, whole_pixels(-by, ppp)); + pan_pixels(vm, (ppp, ui_zoom), whole_pixels(-by, ppp)); } if moved != egui::Vec2::ZERO { scroll_view(app, ui, -moved); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ef6a6d20c8..68729b861c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -870,7 +870,7 @@ fn rotate_view_pointer( None => { let rect = app.canvas_rect.ok_or("no canvas yet")?; let view = app.view().ok_or("no document")?; - RotateViewPress { window, document_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation } + RotateViewPress { window, document_resets, xf: Xf::at(rect, view, app.canvas_ui_zoom), start_angle: 0.0, start_rotation: view.rotation } } }; // A gesture cannot turn a different document/view or resume after its document was reset. @@ -975,7 +975,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con Err(e) => return err(e), }; let view = app.view_info(); - let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + let xf = app.canvas_xf(); let screen = |e: &Value| e.get("space").and_then(Value::as_str) == Some("screen"); if !rotating && xf.is_none() && events.iter().any(screen) { return err("no canvas yet"); diff --git a/crates/ui-egui/src/dialogs/action_set_options.rs b/crates/ui-egui/src/dialogs/action_set_options.rs index f4c2ecdedf..357f6e6840 100644 --- a/crates/ui-egui/src/dialogs/action_set_options.rs +++ b/crates/ui-egui/src/dialogs/action_set_options.rs @@ -6,8 +6,9 @@ //! also holds the action's function key and color, as New Action does (`key`, `cmd`, `shift`, //! `color`, see [`super::action_new`]). OK adds the set and makes it the current //! one, renames the set, or changes the action's name, function key and color -//! (`ui.action.options`). An invalid `set`, `action` or function key is an error. OK then changes no set or -//! action, and the dialog stays open. +//! (`ui.action.options`). An invalid `set`, `action` or function key is an error, and so is a name +//! that would give the set a file that Load Actions… refuses (larger than 8 MiB). OK then changes no +//! set or action, and the dialog stays open. use serde_json::{Value, json}; @@ -83,10 +84,17 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { p["name"] = json!(name); app.run(crate::panels::actions::ACTION_OPTIONS, p)?; } else if let Some(set) = d.fields.get("set") { - let s = set.as_u64().and_then(|i| usize::try_from(i).ok()).and_then(|i| app.ui.action_sets.get_mut(i)).ok_or("no such action set")?; - s.name = name; + let i = set.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|&i| i < app.ui.action_sets.len()).ok_or("no such action set")?; + let mut edited = app.ui.action_sets[i].clone(); + edited.name = name; + crate::panels::actions::edit_keeps_set_loadable(app, Some(i), &edited)?; + app.ui.action_sets[i] = edited; + // A recording into the set measures its file again. + app.ui.recording_base = None; } else { - app.ui.action_sets.push(crate::panels::actions::ActionSet::new(name, vec![])); + let set = crate::panels::actions::ActionSet::new(name, vec![]); + crate::panels::actions::edit_keeps_set_loadable(app, None, &set)?; + app.ui.action_sets.push(set); app.ui.action_set = Some(app.ui.action_sets.len() - 1); } app.ui.dialog = None; diff --git a/crates/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs index c511bb2b58..8bc0e66a2e 100644 --- a/crates/ui-egui/src/dialogs/brush_art_options.rs +++ b/crates/ui-egui/src/dialogs/brush_art_options.rs @@ -10,8 +10,8 @@ //! Scatter: per value (`size`, `spacing`, `scatter` %, `rotation` °) `…Min`, `…Max` and `…Mode` //! (`fixed` | `random` | `pressure` | `tilt` | `bearing` | `rotation`), `rotationRelativeTo` (`page` | //! `path`). -//! Art: `width` (%), `widthMode` (`fixed` | `pressure`), `widthMax` (the width at the heaviest pen -//! pressure, %), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and +//! Art: `width` (%), `widthMode` (`fixed` | `pressure` | `tilt` | `bearing` | `rotation`), +//! `widthMax` (the width at the heaviest pen pressure, upright or at 180°, %), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and //! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`, //! `overlap` (bend on each run between sharp corners). //! Pattern: `scale`, `spacing` (%), `fit` (`stretch` | `addSpace` | `approximate`), `flipAlong`, @@ -254,12 +254,9 @@ fn scatter_mode(d: &Dialog, key: &str) -> Variation { Variation::parse(&d.str(&mode_key(key))).unwrap_or_default() } -/// How an art brush's width varies: Fixed or Pressure. +/// How an art brush's width varies: Fixed, or with the pen's pressure, tilt, bearing or rotation. fn art_width_mode(d: &Dialog) -> Variation { - match Variation::parse(&d.str("widthMode")) { - Some(Variation::Pressure) => Variation::Pressure, - _ => Variation::Fixed, - } + Variation::parse(&d.str("widthMode")).filter(|m| m.follows_pen()).unwrap_or(Variation::Fixed) } /// `brush.options` params from the fields. @@ -282,11 +279,12 @@ pub(super) fn params(d: &Dialog) -> Value { }, _ => ArtScale::Stretch, }; - let pressure = art_width_mode(d) == Variation::Pressure; + let mode = art_width_mode(d); + let pen = mode.follows_pen(); json!({ "width": clamped(d, "width", ArtBrush::WIDTH_RANGE), - "width_mode": if pressure { json!(Variation::Pressure.id()) } else { Value::Null }, - "width_max": if pressure { json!(clamped(d, "widthMax", ArtBrush::WIDTH_RANGE)) } else { Value::Null }, + "width_mode": if pen { json!(mode.id()) } else { Value::Null }, + "width_max": if pen { json!(clamped(d, "widthMax", ArtBrush::WIDTH_RANGE)) } else { Value::Null }, "scale": serde_json::to_value(scale).unwrap_or_default(), "direction": id_of(&parse_field::(d, "direction")), "flip_along": d.bool("flipAlong"), @@ -462,14 +460,14 @@ fn scatter_body(ui: &mut egui::Ui, d: &mut Dialog) { } fn art_body(ui: &mut egui::Ui, d: &mut Dialog) { - // Width, then the width at the heaviest pen pressure (only with Pressure) and how it varies. + // Width, then the second width (only when the width follows the pen) and how it varies. let rail = Tokens::get(ui.ctx()).input_border; let range = ArtBrush::WIDTH_RANGE; widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| { super::form::slider_field_named(ui, d, "width", tl!("Width:"), range.0..=range.1, "%", &|_| rail); let mode = art_width_mode(d); - number(ui, d, "widthMax", range, "%", mode == Variation::Pressure); - let modes = [Variation::Fixed, Variation::Pressure]; + number(ui, d, "widthMax", range, "%", mode.follows_pen()); + let modes = [Variation::Fixed, Variation::Pressure, Variation::Tilt, Variation::Bearing, Variation::Rotation]; if let Some(m) = widgets::dropdown(ui, "brush-art-width-mode", mode.label(), &modes.map(Variation::label), 104.0).and_then(|i| modes.get(i)) { d.fields.insert("widthMode".into(), json!(m.id())); } diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 3ae028bb05..07577c127c 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -275,6 +275,29 @@ fn art_options_width_follows_the_pen_pressure() { assert!((drawn_height(&app, id) - before).abs() < 1e-6); } +/// Art Brush Options' Width menu also offers Tilt, Bearing and Rotation, each with the second +/// width, and shows the one set when it opens again. +#[test] +fn art_options_width_follows_tilt_bearing_and_rotation() { + let mut app = app(); + let (id, before) = brushed(&mut app, "Tapered Stroke"); + for (mode, label) in [("tilt", "Tilt"), ("bearing", "Bearing"), ("rotation", "Rotation")] { + app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap(); + set(&mut app, json!({"widthMode": mode, "widthMax": 300})); + assert!(shown(&mut app).contains(label), "{mode}"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap(); + assert_eq!((&def["width_mode"], &def["width_max"]), (&json!(mode), &json!(300.0)), "{mode}"); + // A stroke drawn without the pen's input is half way: 200 %. + let after = drawn_height(&app, id); + assert!((after - 2.0 * before).abs() < 0.01 * before, "{mode}: {before} then {after}"); + app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), (mode.into(), 300.0), "{mode}"); + super::super::cancel(&mut app); + } +} + /// A context with the app's fonts and theme, for [`frame`]. fn ctx() -> egui::Context { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 54df0ab912..8e4678ae50 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -186,9 +186,22 @@ pub(crate) fn swatch_choices(doc: &vectorcraft_doc::Document) -> Value { Value::Array(choices.collect()) } -/// A feature tag without its `-` or `+`. +/// A feature tag without its `-` or `+` prefix and its `=N` value. fn bare(tag: &str) -> &str { - tag.trim_start_matches(['-', '+']) + let tag = tag.trim_start_matches(['-', '+']); + tag.split_once('=').map_or(tag, |(name, _)| name) +} + +/// Is the feature a tag of a style's list sets on? A `tag=N` of a valued feature is off for 0, +/// which a `-` prefix check misses; every other tag is on unless it starts with `-`. +fn tag_on(tag: &str) -> bool { + let name = bare(tag); + let valued = name == "swsh" || vectorcraft_text::EXTRA_FEATURES.contains(&name); + if valued && vectorcraft_text::OtFeatures::known_tag(tag) { + vectorcraft_text::OtFeatures::from_tags([tag]).feature_value(name).is_some() + } else { + !tag.starts_with('-') + } } /// The OpenType Features fields for a style's `features`: each control is unset unless the list @@ -201,9 +214,9 @@ fn opentype_fields(features: Option<&Value>) -> Map { let mut fields = Map::new(); for (field, tag) in OT_SWITCHES { let last = tags.iter().rev().find(|t| bare(t) == *tag); - fields.insert((*field).into(), last.map_or(Value::Null, |t| json!(!t.starts_with('-')))); + fields.insert((*field).into(), last.map_or(Value::Null, |t| json!(tag_on(t)))); } - let last_on = |tag: &str| tags.iter().rev().find(|t| bare(t) == tag).map(|t| !t.starts_with('-')); + let last_on = |tag: &str| tags.iter().rev().find(|t| bare(t) == tag).map(|t| tag_on(t)); let figure = names(&FIGURE_TAGS) .then(|| FIGURE_VALUES.get(crate::panels::opentype::figure(&f))) .flatten() @@ -214,7 +227,7 @@ fn opentype_fields(features: Option<&Value>) -> Map { fields.insert("ot_position".into(), position.map_or(Value::Null, |v| json!(v))); let sets: Vec = vectorcraft_text::EXTRA_FEATURES[..20] .iter() - .filter_map(|set| tags.iter().rev().find(|t| bare(t) == *set).map(|t| if t.starts_with('-') { format!("-{set}") } else { set.to_string() })) + .filter_map(|set| tags.iter().rev().find(|t| bare(t) == *set).map(|t| if tag_on(t) { set.to_string() } else { format!("-{set}") })) .collect(); fields.insert("ot_sets".into(), if sets.is_empty() { Value::Null } else { json!(sets) }); fields @@ -492,9 +505,18 @@ fn opentype_attrs(d: &Dialog, original: &Map, original_fields: &M } if !unchanged("ot_sets") { let sets = &vectorcraft_text::EXTRA_FEATURES[..20]; + // A set still on keeps the value its tag gives (`ss01=2`). + let before: Vec = tags.iter().filter(|t| sets.contains(&bare(t))).cloned().collect(); tags.retain(|t| !sets.contains(&bare(t))); let listed = d.fields.get("ot_sets").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str); - tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(str::to_string)); + tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(|t| { + let kept = before + .iter() + .rev() + .find(|b| bare(b) == bare(t)) + .filter(|b| !t.starts_with('-') && vectorcraft_text::OtFeatures::known_tag(b) && tag_on(b)); + kept.map_or_else(|| t.to_string(), Clone::clone) + })); } if !tags.is_empty() { attrs.insert("features".into(), json!(tags)); @@ -1724,6 +1746,21 @@ mod tests { assert_eq!(edited_attrs(&d).unwrap(), Map::new()); } + #[test] + fn a_stored_zero_value_reads_as_off_and_turning_it_on_saves_it() { + let mut d = dialog_for(json!({"features": ["ss01=0", "salt=0", "ss02=2"]})); + assert_eq!(d.fields["ot_salt"], json!(false)); + assert_eq!(d.fields["ot_sets"], json!(["-ss01", "ss02"])); + d.fields.insert("ot_sets".into(), json!(["ss01", "ss02"])); + d.fields.insert("ot_salt".into(), json!(true)); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["salt", "ss01", "ss02=2"])); + for last in ["ss01=0", "-ss01", "ss01=65536"] { + let mut d = dialog_for(json!({"features": ["ss01=2", last]})); + d.fields.insert("ot_sets".into(), json!(["ss01", "ss02"])); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["ss01", "ss02"]), "{last}: only a valid last on-value is kept"); + } + } + #[test] fn figure_tags_no_menu_entry_matches_leave_the_figure_unset() { for features in [json!(["pnum"]), json!(["lnum"]), json!(["onum", "lnum"]), json!(["tnum", "pnum"])] { diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 7c9aaf6873..2e277caea8 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -64,12 +64,12 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// Does this dialog show a Preview checkbox? Grid Repeat Options does: the public page for the /// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does /// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path -/// works the same way (#5532), and so does Image Tools › Remove Background. (Cancel rolls a -/// preview back whichever dialog started it.) +/// works the same way (#5532), and so do Image Tools › Remove Background and Cutting Tools › Weed +/// Box and Lines. (Cancel rolls a preview back whichever dialog started it.) fn previews(d: &Dialog) -> bool { match d.str("__command").as_str() { "object.repeat.options" => d.fields.contains_key("gridType"), - "type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND => true, + "type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND | crate::addons::cutting::WEED => true, _ => false, } } @@ -87,6 +87,19 @@ fn call(d: &Dialog) -> (String, Value) { { o.remove("rowGutter"); } + // Weed Box and Lines: a Spacing of 0, or none, leaves the Count in use; any other Spacing is + // sent instead of the Count, so the command refuses one below 0. + if d.str("__command") == crate::addons::cutting::WEED + && let Some(o) = params.as_object_mut() + { + let zero = |s: &Value| match s { + Value::Null => true, + Value::String(t) => vectorcraft_doc::Unit::Points.parse(t).or_else(|| t.trim().parse().ok()) == Some(0.0), + v => v.as_f64() == Some(0.0), + }; + let by_count = o.get("spacing").is_none_or(zero); + o.remove(if by_count { "spacing" } else { "count" }); + } (d.str("__command"), params) } @@ -102,6 +115,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "graph.create" => &["width", "height"], "shape.flare" => &["diameter", "pathLength"], "perspective.grid.set" => &["cell", "distance"], + crate::addons::cutting::WEED => &["margin", "spacing", "gap", "weight"], "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"], "type.pathOptions" => &["spacing"], @@ -115,6 +129,7 @@ fn lengths(command: &str) -> &'static [&'static str] { /// names isn't it (the rest follow, by name). fn order(command: &str) -> &'static [&'static str] { match command { + crate::addons::cutting::WEED => &["margin", "box", "direction", "count", "spacing", "gap", "weight", "swatch"], // The public Trap options' order. "object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"], "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], @@ -135,6 +150,7 @@ fn choices(command: &str, key: &str) -> Option { ("text.areaOptions", "fit") => Some(AREA_FIT), ("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE), ("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN), + (crate::addons::cutting::WEED, "direction") => Some(crate::addons::cutting::WEED_DIRECTIONS), ("graph.setType", "type") => Some(GRAPH_KINDS.as_slice()), ("graph.setType", "valueAxis") => Some(GRAPH_VALUE_AXIS), ("graph.setType", "tickLength" | "rightTickLength" | "categoryTickLength") => Some(GRAPH_TICK_LENGTH), diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index c4c043b39e..63dcfa916b 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -92,9 +92,8 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0)); let area = egui::Id::unique("gradient-stop-popover"); let size = ctx.memory(|m| m.area_rect(area)).map_or(POPOVER_SIZE, |r| r.size()); - let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.view().copied()) { - Some((canvas, v)) => { - let xf = Xf::new(canvas, &v); + let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.canvas_xf()) { + Some((canvas, xf)) => { let at = xf.to_screen(chip); selection_on_screen(app, &xf).map_or(at + vec2(14.0, 14.0), |art| beside_art(at, art, size, canvas)) } diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index 5016eacd06..4595447ac2 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -92,7 +92,7 @@ impl Geometry { let state = app.session.active()?; let node = state.doc.node(*state.selection.objects.first()?)?; let bounds = node.geometric_bounds()?; - let xf = Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let options = super::revolve::light_options(d); // Both Revolves turn about their axis; the others about the centre of the art's bounds. let id = d.str("__effect"); diff --git a/crates/ui-egui/src/dialogs/simplify_slider.rs b/crates/ui-egui/src/dialogs/simplify_slider.rs index 820ab9af90..88838585ee 100644 --- a/crates/ui-egui/src/dialogs/simplify_slider.rs +++ b/crates/ui-egui/src/dialogs/simplify_slider.rs @@ -202,8 +202,7 @@ fn bounds(app: &mut VectorcraftApp, d: &mut Dialog) -> Option { fn place(app: &mut VectorcraftApp, ctx: &egui::Context, id: egui::Id, bounds: Option) -> Pos2 { let size = egui::AreaState::load(ctx, id).and_then(|s| s.size).unwrap_or(vec2(270.0, 42.0)); let screen = ctx.content_rect(); - let Some((rect, view)) = app.canvas_rect.zip(app.view().copied()) else { return screen.center() - size / 2.0 }; - let xf = crate::canvas::Xf::new(rect, &view); + let Some((rect, xf)) = app.canvas_rect.zip(app.canvas_xf()) else { return screen.center() - size / 2.0 }; let under = match bounds { Some(b) => { let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d177ae3b66..4ca1e345f9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -879,6 +879,17 @@ font-technology Variable Променливи The selected character's font is not available. Шрифтът на избрания знак не е наличен. No alternates for the selected character. Няма алтернативи за избрания знак. Double-click to replace Двойно щракване за замяна + Select one character with the Type tool first. Първо изберете един знак с инструмента „Текст“. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} е глиф на {font}, а не на шрифта, с който е зададен избраният текст ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} принадлежи на {base} (U+{base_code}) {place}; избраният от вас знак е {sel} (U+{code}). + at the start of a word в началото на дума + in the middle of a word в средата на дума + at the end of a word в края на дума + standing alone когато стои сам + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не е алтернатива на {sel} (U+{code}) там, където е в думата си. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двойно щракване за вмъкване на {base} (U+{code}): представителната форма не се свързва, буквата се свързва. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вмъкнато е {base} (U+{code}) вместо представителна форма: шрифтът я свързва със съседите според позицията. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Нито една OpenType функция не рисува този глиф тук, затова той заменя глифа на знака и може да не се свързва със съседните знаци. Double-click to insert Двойно щракване за вмъкване Double-click to rename Двойно щракване за преименуване Double-click to target the contents Двойно щракване за насочване към съдържанието @@ -2853,6 +2864,7 @@ digits Malayalam Малаялам Rotate View Tool Инструмент „Завъртане на изглед“ Trackpad Gesture to Rotate View Завъртане на изгледа с жест на тракпада Hold a Tool Shortcut to Use the Tool Temporarily Задържане на клавиша на инструмент за временното му използване + Show Bounding Box While Moving and Drawing Показване на ограничителна рамка при преместване и рисуване Rotate… Завъртане… Rotation Завъртане Roughen… Грапавост… @@ -6690,4 +6702,9 @@ breakText Glyphs Глифи Stencil Bridges… Мостчета на шаблона… Stencil Bridges Мостчета на шаблона Shortest Crossing Най-късо пресичане + Weed Box and Lines… Рамка и линии за почистване… + Weed Box and Lines Рамка и линии за почистване + Margin Поле + Box Рамка + Swatch Образец Direction Посока diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 7398052854..972414922b 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1400,6 +1400,7 @@ lighting Light Svjetlo Rotate View Tool Alat za rotaciju prikaza Trackpad Gesture to Rotate View Pokret dodirne ploče za rotaciju prikaza Hold a Tool Shortcut to Use the Tool Temporarily Držite prečicu alata za privremenu upotrebu alata + Show Bounding Box While Moving and Drawing Prikaži granični okvir pri pomjeranju i crtanju Rotate View Rotiraj prikaz Rounded Rectangle Tool Alat zaobljeni pravougaonik Scale Tool Alat za skaliranje @@ -1678,6 +1679,17 @@ digits Malayalam Malajalam The selected character's font is not available. Font odabranog znaka nije dostupan. No alternates for the selected character. Nema alternativa za odabrani znak. Double-click to replace Dvostruki klik za zamjenu + Select one character with the Type tool first. Prvo odaberite jedan znak alatom za tekst. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glif fonta {font}, a ne fonta kojim je postavljen odabrani tekst ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pripada znaku {base} (U+{base_code}) {place}; vaš odabir je {sel} (U+{code}). + at the start of a word na početku riječi + in the middle of a word usred riječi + at the end of a word na kraju riječi + standing alone kad stoji sam + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nije alternativa znaku {sel} (U+{code}) onako kako stoji u svojoj riječi. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvostruki klik za umetanje znaka {base} (U+{code}): oblik za prikaz se ne spaja, slovo se spaja. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Umetnut je znak {base} (U+{code}) umjesto oblika za prikaz: font ga spaja sa susjedima prema položaju. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nijedna OpenType funkcija ne crta ovaj glif ovdje, pa on zamjenjuje glif znaka i možda se neće spajati sa susjednim znakovima. Double-click to insert Dvostruki klik za umetanje Entire Font Cijeli font glyph {id} glif {id} @@ -6744,4 +6756,9 @@ shape Open Otvoren Stencil Bridges… Mostići šablona… Stencil Bridges Mostići šablona Shortest Crossing Najkraći prijelaz + Weed Box and Lines… Okvir i linije za čišćenje… + Weed Box and Lines Okvir i linije za čišćenje + Margin Margina + Box Okvir + Swatch Uzorak boje Direction Smjer diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 6976e84bf3..ebfbcd26db 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1288,6 +1288,17 @@ antiAlias Sharp Nítid The selected character's font is not available. El tipus de lletra del caràcter seleccionat no està disponible. No alternates for the selected character. No hi ha alternatives per al caràcter seleccionat. Double-click to replace Feu doble clic per substituir + Select one character with the Type tool first. Selecciona primer un caràcter amb l’eina de text. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} és un glif de {font}, no del tipus de lletra amb què està escrit el text seleccionat ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertany a {base} (U+{base_code}) {place}; la teva selecció és {sel} (U+{code}). + at the start of a word a l’inici d’una paraula + in the middle of a word al mig d’una paraula + at the end of a word al final d’una paraula + standing alone quan va sol + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no és una alternativa de {sel} (U+{code}) tal com es troba a la seva paraula. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Feu doble clic per inserir {base} (U+{code}): una forma de presentació no s’uneix, la lletra sí. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. S’ha inserit {base} (U+{code}) en lloc d’una forma de presentació: el tipus de lletra l’uneix als veïns segons la posició. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Cap funció OpenType dibuixa aquest glif aquí, de manera que substitueix el glif del caràcter i pot no unir-se als caràcters veïns. Double-click to insert Feu doble clic per inserir Double-click to rename Feu doble clic per canviar el nom Double-click to target the contents Feu doble clic per apuntar al contingut @@ -4658,6 +4669,7 @@ digits Malayalam Malaiàlam Tracking Espaiat Trackpad Gesture to Rotate View Gesticulació del trackpad per girar la vista Hold a Tool Shortcut to Use the Tool Temporarily Mantén premuda la drecera d’una eina per usar-la temporalment + Show Bounding Box While Moving and Drawing Mostra el quadre delimitador en moure i dibuixar Trademark Symbol Símbol de marca comercial Transform Transforma Transform Again Transforma de nou @@ -6995,3 +7007,8 @@ breakText Glyphs Glifs @msg Some islands could not be joined to the area around the art Algunes illes no s’han pogut unir a l’àrea que envolta la il·lustració @msg `width` must be from {lo} to {hi} pt `width` ha d’estar entre {lo} i {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` ha de ser un nombre enter d’1 a {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Caixa i línies de pelat… + Weed Box and Lines Caixa i línies de pelat + Margin Marge + Box Caixa + Swatch Mostra diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f0137063e0..f5ea30ac2b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1426,6 +1426,7 @@ lighting Light Světlo Rotate View Tool Nástroj Otočení zobrazení Trackpad Gesture to Rotate View Otáčet zobrazení gestem na trackpadu Hold a Tool Shortcut to Use the Tool Temporarily Podržením klávesové zkratky nástroj dočasně použít + Show Bounding Box While Moving and Drawing Zobrazit ohraničovací rámeček při přesouvání a kreslení Rotate View Otočení zobrazení Rounded Rectangle Tool Nástroj Zaoblený obdélník Scale Tool Nástroj Změna velikosti @@ -1707,6 +1708,17 @@ digits Malayalam Malajálamské The selected character's font is not available. Písmo vybraného znaku není k dispozici. No alternates for the selected character. Vybraný znak nemá žádné alternativy. Double-click to replace Dvojklikem nahradíte + Select one character with the Type tool first. Nejprve nástrojem Text vyberte jeden znak. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glyf písma {font}, ne písma, kterým je vybraný text nastaven ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} patří znaku {base} (U+{base_code}) {place}; váš výběr je {sel} (U+{code}). + at the start of a word na začátku slova + in the middle of a word uprostřed slova + at the end of a word na konci slova + standing alone když stojí samostatně + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} není alternativou znaku {sel} (U+{code}) tak, jak stojí ve svém slově. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvojklikem vložíte {base} (U+{code}): prezentační tvar se nespojuje, písmeno ano. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Vloženo {base} (U+{code}) místo prezentačního tvaru: písmo ho podle polohy spojí se sousedy. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Žádná funkce OpenType tento glyf zde nekreslí, proto nahradí glyf znaku a nemusí se spojit se sousedními znaky. Double-click to insert Dvojklikem vložíte Entire Font Celé písmo glyph {id} glyf {id} @@ -7014,3 +7026,8 @@ breakText Glyphs Glyfy @msg Some islands could not be joined to the area around the art Některé ostrůvky nebylo možné připojit k oblasti kolem kresby @msg `width` must be from {lo} to {hi} pt `width` musí být od {lo} do {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musí být celé číslo od 1 do {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Rámeček a linie pro vybírání… + Weed Box and Lines Rámeček a linie pro vybírání + Margin Okraj + Box Rámeček + Swatch Vzorek diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 0ff647a29f..fbc690cf7f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1335,6 +1335,7 @@ lighting Light Lys Rotate View Tool Visningsroteringsværktøj Trackpad Gesture to Rotate View Rotér visning med bevægelse på pegefeltet Hold a Tool Shortcut to Use the Tool Temporarily Hold et værktøjs genvejstast nede for at bruge værktøjet midlertidigt + Show Bounding Box While Moving and Drawing Vis afgrænsningsramme under flytning og tegning Rotate View Rotér visning Rounded Rectangle Tool Afrundet rektangelværktøj Scale Tool Skaleringsværktøj @@ -1613,6 +1614,17 @@ digits Malayalam Malayalam The selected character's font is not available. Skrifttypen for det markerede tegn er ikke tilgængelig. No alternates for the selected character. Ingen alternativer til det markerede tegn. Double-click to replace Dobbeltklik for at erstatte + Select one character with the Type tool first. Markér først ét tegn med tekstværktøjet. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} er et glyf i {font}, ikke i den skrifttype, den markerede tekst er sat i ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hører til {base} (U+{base_code}) {place}; din markering er {sel} (U+{code}). + at the start of a word i begyndelsen af et ord + in the middle of a word midt i et ord + at the end of a word i slutningen af et ord + standing alone når det står alene + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} er ikke et alternativ til {sel} (U+{code}), som det står i sit ord. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dobbeltklik for at indsætte {base} (U+{code}) i stedet: en præsentationsform forbindes ikke, bogstavet gør. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) er indsat i stedet for en præsentationsform: skrifttypen forbinder det med naboerne efter position. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion tegner dette glyf her, så det erstatter tegnets glyf og forbindes måske ikke med nabotegnene. Double-click to insert Dobbeltklik for at indsætte Entire Font Hele skrifttypen glyph {id} glyf {id} @@ -6706,4 +6718,9 @@ breakText Glyphs Glyffer Stencil Bridges… Stencilbroer… Stencil Bridges Stencilbroer Shortest Crossing Korteste krydsning + Weed Box and Lines… Lugeramme og lugelinjer… + Weed Box and Lines Lugeramme og lugelinjer + Margin Margen + Box Ramme + Swatch Farveprøve Direction Retning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index cdf8211c14..e776bc1264 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -881,6 +881,17 @@ font-technology Variable Variabel The selected character's font is not available. Die Schrift des ausgewählten Zeichens ist nicht verfügbar. No alternates for the selected character. Keine Alternativen für das ausgewählte Zeichen. Double-click to replace Doppelklicken zum Ersetzen + Select one character with the Type tool first. Wählen Sie zuerst mit dem Text-Werkzeug ein Zeichen aus. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} ist eine Glyphe von {font}, nicht der Schrift, in der der ausgewählte Text gesetzt ist ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} gehört zu {base} (U+{base_code}) {place}; Ihre Auswahl ist {sel} (U+{code}). + at the start of a word am Wortanfang + in the middle of a word in der Wortmitte + at the end of a word am Wortende + standing alone allein stehend + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ist keine Alternative zu {sel} (U+{code}), so wie es in seinem Wort steht. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doppelklicken, um stattdessen {base} (U+{code}) einzufügen: Eine Darstellungsform verbindet sich nicht, der Buchstabe schon. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) statt einer Darstellungsform eingefügt: Die Schrift verbindet es je nach Position mit den Nachbarn. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Keine OpenType-Funktion zeichnet diese Glyphe hier, daher ersetzt sie die Glyphe des Zeichens und verbindet sich möglicherweise nicht mit den Nachbarzeichen. Double-click to insert Doppelklicken zum Einfügen Double-click to rename Doppelklicken zum Umbenennen Double-click to target the contents Doppelklicken, um den Inhalt als Ziel auszuwählen @@ -2899,6 +2910,7 @@ digits Malayalam Malayalam Rotate View Tool Ansichtdrehung-Werkzeug Trackpad Gesture to Rotate View Ansicht mit Trackpad-Geste drehen Hold a Tool Shortcut to Use the Tool Temporarily Werkzeug-Tastaturbefehl gedrückt halten, um das Werkzeug vorübergehend zu verwenden + Show Bounding Box While Moving and Drawing Begrenzungsrahmen beim Verschieben und Zeichnen einblenden Rotate… Drehen… Rotation Drehung Roughen… Aufrauen… @@ -6609,3 +6621,8 @@ breakText Glyphs Glyphen @msg Some islands could not be joined to the area around the art Einige Inseln konnten nicht mit der Fläche um das Bildmaterial verbunden werden @msg `width` must be from {lo} to {hi} pt `width` muss zwischen {lo} und {hi} pt liegen @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` muss eine ganze Zahl von 1 bis {MAX_BRIDGES_PER_ISLAND} sein + Weed Box and Lines… Entgitterrahmen und -linien… + Weed Box and Lines Entgitterrahmen und -linien + Margin Rand + Box Rahmen + Swatch Farbfeld diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c2d2c8ae00..711bdeb099 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -852,6 +852,17 @@ font-technology Variable Μεταβλητές The selected character's font is not available. Η γραμματοσειρά του επιλεγμένου χαρακτήρα δεν είναι διαθέσιμη. No alternates for the selected character. Δεν υπάρχουν εναλλακτικοί για τον επιλεγμένο χαρακτήρα. Double-click to replace Διπλό κλικ για αντικατάσταση + Select one character with the Type tool first. Επιλέξτε πρώτα έναν χαρακτήρα με το εργαλείο κειμένου. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). Ο γλύφος {glyph} ανήκει στη γραμματοσειρά {font}, όχι σε αυτήν του επιλεγμένου κειμένου ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). Ο γλύφος {glyph} ανήκει στο {base} (U+{base_code}) {place}· η επιλογή σας είναι {sel} (U+{code}). + at the start of a word στην αρχή μιας λέξης + in the middle of a word στη μέση μιας λέξης + at the end of a word στο τέλος μιας λέξης + standing alone όταν στέκεται μόνος + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. Ο γλύφος {glyph} δεν είναι εναλλακτικός του {sel} (U+{code}) όπως βρίσκεται στη λέξη του. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Διπλό κλικ για εισαγωγή του {base} (U+{code}): μια μορφή παρουσίασης δεν συνδέεται, το γράμμα συνδέεται. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Εισήχθη το {base} (U+{code}) αντί για μορφή παρουσίασης: η γραμματοσειρά το συνδέει με τους γείτονες ανάλογα με τη θέση. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Καμία λειτουργία OpenType δεν σχεδιάζει αυτόν τον γλύφο εδώ, οπότε αντικαθιστά τον γλύφο του χαρακτήρα και μπορεί να μην συνδέεται με τους γειτονικούς χαρακτήρες. Double-click to insert Διπλό κλικ για εισαγωγή Double-click to rename Διπλό κλικ για μετονομασία Double-click to target the contents Διπλό κλικ για επιλογή του περιεχομένου ως στόχου @@ -1973,6 +1984,7 @@ font-technology Variable Μεταβλητές Rotate View Tool Εργαλείο περιστροφής προβολής (Rotate View Tool) Trackpad Gesture to Rotate View Περιστροφή προβολής με χειρονομία στην επιφάνεια αφής Hold a Tool Shortcut to Use the Tool Temporarily Προσωρινή χρήση εργαλείου κρατώντας πατημένη τη συντόμευσή του + Show Bounding Box While Moving and Drawing Εμφάνιση πλαισίου οριοθέτησης κατά τη μετακίνηση και τη σχεδίαση Rotate… Περιστροφή… Rotation Περιστροφή Rows: Γραμμές: @@ -6966,3 +6978,8 @@ breakText Glyphs Γλύφοι @msg Some islands could not be joined to the area around the art Ορισμένες νησίδες δεν ήταν δυνατό να συνδεθούν με την περιοχή γύρω από το γραφικό @msg `width` must be from {lo} to {hi} pt Το `width` πρέπει να είναι από {lo} έως {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} Το `count` πρέπει να είναι ακέραιος από 1 έως {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Πλαίσιο και γραμμές αφαίρεσης περισσευμάτων… + Weed Box and Lines Πλαίσιο και γραμμές αφαίρεσης περισσευμάτων + Margin Περιθώριο + Box Πλαίσιο + Swatch Δείγμα χρώματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 71049f9db3..2ab24e227f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -894,6 +894,17 @@ font-technology Variable Variables The selected character's font is not available. La fuente del carácter seleccionado no está disponible. No alternates for the selected character. No hay alternativas para el carácter seleccionado. Double-click to replace Doble clic para reemplazar + Select one character with the Type tool first. Primero selecciona un carácter con la herramienta Texto. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} es un glifo de {font}, no de la fuente del texto seleccionado ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertenece a {base} (U+{base_code}) {place}; tu selección es {sel} (U+{code}). + at the start of a word al principio de una palabra + in the middle of a word en medio de una palabra + at the end of a word al final de una palabra + standing alone cuando está solo + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no es una alternativa de {sel} (U+{code}) tal como está en su palabra. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doble clic para insertar {base} (U+{code}) en su lugar: una forma de presentación no se une, la letra sí. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Se insertó {base} (U+{code}) en lugar de una forma de presentación: la fuente la une a sus vecinas según la posición. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ninguna función OpenType dibuja este glifo aquí, así que sustituye el glifo del carácter y puede que no se una a los caracteres vecinos. Double-click to insert Doble clic para insertar Double-click to rename Doble clic para cambiar el nombre Double-click to target the contents Doble clic para seleccionar el contenido como destino @@ -2932,6 +2943,7 @@ digits Malayalam Malayálam Rotate View Tool Herramienta Rotar vista Trackpad Gesture to Rotate View Rotar vista con un gesto del trackpad Hold a Tool Shortcut to Use the Tool Temporarily Mantener pulsado el atajo de una herramienta para usarla temporalmente + Show Bounding Box While Moving and Drawing Mostrar cuadro delimitador al mover y dibujar Rotate… Rotar… Rotation Rotación Roughen… Hacer áspero… @@ -6957,3 +6969,8 @@ breakText Glyphs Glifos @msg Some islands could not be joined to the area around the art Algunas islas no se pudieron unir al área que rodea la ilustración @msg `width` must be from {lo} to {hi} pt `width` debe estar entre {lo} y {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` debe ser un número entero entre 1 y {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Caja y líneas de pelado… + Weed Box and Lines Caja y líneas de pelado + Margin Margen + Box Caja + Swatch Muestra diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1880b5a4c4..650898ae92 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1402,6 +1402,7 @@ lighting Light Valo Rotate View Tool Näkymän kiertotyökalu Trackpad Gesture to Rotate View Kierrä näkymää ohjauslevyn eleellä Hold a Tool Shortcut to Use the Tool Temporarily Käytä työkalua tilapäisesti pitämällä sen pikanäppäintä painettuna + Show Bounding Box While Moving and Drawing Näytä rajausruutu siirrettäessä ja piirrettäessä Rotate View Kierrä näkymää Rounded Rectangle Tool Pyöristetyn suorakulmion työkalu Scale Tool Skaalaustyökalu @@ -1680,6 +1681,17 @@ digits Malayalam Malajalam The selected character's font is not available. Valitun merkin fontti ei ole saatavilla. No alternates for the selected character. Valitulla merkillä ei ole vaihtoehtoja. Double-click to replace Korvaa kaksoisnapsauttamalla + Select one character with the Type tool first. Valitse ensin yksi merkki tekstityökalulla. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} on fontin {font} glyfi, ei valitun tekstin fontin ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} kuuluu merkille {base} (U+{base_code}) {place}; valintasi on {sel} (U+{code}). + at the start of a word sanan alussa + in the middle of a word sanan keskellä + at the end of a word sanan lopussa + standing alone yksinään + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ei ole merkin {sel} (U+{code}) vaihtoehto sellaisena kuin se on sanassaan. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Lisää kaksoisnapsauttamalla sen sijaan {base} (U+{code}): esitysmuoto ei liity, kirjain liittyy. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Lisättiin {base} (U+{code}) esitysmuodon sijaan: fontti liittää sen naapureihin sijainnin mukaan. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Mikään OpenType-ominaisuus ei piirrä tätä glyfiä tässä, joten se korvaa merkin glyfin eikä välttämättä liity viereisiin merkkeihin. Double-click to insert Lisää kaksoisnapsauttamalla Entire Font Koko fontti glyph {id} merkki {id} @@ -7049,3 +7061,8 @@ breakText Glyphs Merkit @msg Some islands could not be joined to the area around the art Joitakin saarekkeita ei voitu yhdistää grafiikkaa ympäröivään alueeseen @msg `width` must be from {lo} to {hi} pt `width`-arvon on oltava {lo}–{hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` on oltava kokonaisluku 1–{MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Karsintakehys ja -viivat… + Weed Box and Lines Karsintakehys ja -viivat + Margin Marginaali + Box Kehys + Swatch Värimalli diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 25413540fa..95e9c9e4c2 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -897,6 +897,17 @@ font-technology Variable Variables The selected character's font is not available. La police du caractère sélectionné n’est pas disponible. No alternates for the selected character. Aucune variante pour le caractère sélectionné. Double-click to replace Double-cliquez pour remplacer + Select one character with the Type tool first. Sélectionnez d’abord un caractère avec l’outil Texte. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} est un glyphe de {font}, et non de la police du texte sélectionné ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartient à {base} (U+{base_code}) {place} ; votre sélection est {sel} (U+{code}). + at the start of a word au début d’un mot + in the middle of a word au milieu d’un mot + at the end of a word à la fin d’un mot + standing alone isolé + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} n’est pas une variante de {sel} (U+{code}) tel qu’il se trouve dans son mot. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Double-cliquez pour insérer {base} (U+{code}) à la place : une forme de présentation ne se lie pas, la lettre si. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) a été inséré à la place d’une forme de présentation : la police le lie à ses voisins selon la position. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Aucune fonction OpenType ne dessine ce glyphe ici : il remplace le glyphe du caractère et peut ne pas se lier aux caractères voisins. Double-click to insert Double-cliquez pour insérer Double-click to rename Double-cliquez pour renommer Double-click to target the contents Double-cliquez pour cibler le contenu @@ -2924,6 +2935,7 @@ digits Malayalam Malayalam Rotate View Tool Outil Faire pivoter l’affichage Trackpad Gesture to Rotate View Faire pivoter l’affichage avec un geste du trackpad Hold a Tool Shortcut to Use the Tool Temporarily Maintenir le raccourci d’un outil pour l’utiliser temporairement + Show Bounding Box While Moving and Drawing Afficher le cadre de sélection pendant le déplacement et le dessin Rotate… Rotation… Rotation Rotation Roughen… Rugosité… @@ -7009,3 +7021,8 @@ breakText Glyphs Glyphes @msg Some islands could not be joined to the area around the art Certains îlots n’ont pas pu être reliés à la zone autour de l’illustration @msg `width` must be from {lo} to {hi} pt `width` doit être compris entre {lo} et {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` doit être un nombre entier compris entre 1 et {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Cadre et lignes d’échenillage… + Weed Box and Lines Cadre et lignes d’échenillage + Margin Marge + Box Cadre + Swatch Nuance diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9bf4e014c8..3d9850d4f5 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1428,6 +1428,17 @@ antiAlias Sharp शार्प Double-click to insert सम्मिलित करने के लिए डबल क्लिक करें Double-click to rename नाम बदलने के लिए डबल क्लिक करें Double-click to replace प्रतिस्थापित करने के लिए डबल क्लिक करें + Select one character with the Type tool first. पहले टाइप टूल से एक कैरेक्टर सिलेक्ट करें। + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} {font} का ग्लिफ़ है, उस फ़ॉन्ट का नहीं जिसमें सिलेक्ट किया टेक्स्ट सेट है ({text_font})। + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} {base} (U+{base_code}) का है, {place}; आपका सिलेक्शन {sel} (U+{code}) है। + at the start of a word शब्द की शुरुआत में + in the middle of a word शब्द के बीच में + at the end of a word शब्द के अंत में + standing alone अकेले खड़े होने पर + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, {sel} (U+{code}) का विकल्प नहीं है, जैसा वह अपने शब्द में खड़ा है। + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. इसके बजाय {base} (U+{code}) सम्मिलित करने के लिए डबल क्लिक करें: प्रेज़ेंटेशन फ़ॉर्म नहीं जुड़ता, अक्षर जुड़ता है। + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. प्रेज़ेंटेशन फ़ॉर्म की जगह {base} (U+{code}) सम्मिलित किया गया: फ़ॉन्ट उसे स्थिति के अनुसार पड़ोसियों से जोड़ता है। + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. यहाँ कोई OpenType फ़ीचर इस ग्लिफ़ को नहीं बनाता, इसलिए यह कैरेक्टर के ग्लिफ़ की जगह लेता है और पड़ोसी कैरेक्टरों से जुड़ नहीं भी सकता। Double-click to target the contents सामग्री को लक्षित करने के लिए डबल क्लिक करें Down (right reading) नीचे (दाएं पढ़ने) Download डाउनलोड @@ -5051,6 +5062,7 @@ antiAlias Sharp शार्प Tracking ट्रैकिंग Trackpad Gesture to Rotate View दृश्य घुमाने के लिए ट्रैकपैड जेस्चर Hold a Tool Shortcut to Use the Tool Temporarily किसी टूल का शॉर्टकट दबाए रखकर उसे अस्थायी रूप से उपयोग करें + Show Bounding Box While Moving and Drawing खिसकाते और बनाते समय बाउंडिंग बॉक्स दिखाएँ Trademark Symbol ट्रेडमार्क सिंबल Transform रूपांतरण करें Transform Again फिर से बदलना @@ -8133,7 +8145,7 @@ antiAlias Sharp शार्प @msg width must be positive width: चौड़ाई सकारात्मक होना चाहिए @msg width out of range सीमा से बाहर चौड़ाई @msg width/height must be > 0 चौड़ाई / ऊंचाई होना चाहिए > 0 -@msg width/height must be positive and within the canvas चौड़ाई / ऊंचाई सकारात्मक होना चाहिए और कैनवास के भीतर होना चाहिए +@msg width and height must be more than 0 and at most {limit} चौड़ाई और ऊंचाई 0 से अधिक और अधिकतम {limit} होनी चाहिए @msg workspace name can't be empty कार्यक्षेत्र का नाम खाली नहीं हो सकता है @msg x and y must be finite x और y को परिमित होना चाहिए @msg x and y must be finite numbers x और y होना चाहिए परिमित संख्या @@ -8447,3 +8459,8 @@ theme Appearance अपीयरेंस @msg Some islands could not be joined to the area around the art कुछ आइलैंड आर्टवर्क के आसपास के क्षेत्र से नहीं जोड़े जा सके @msg `width` must be from {lo} to {hi} pt `width` {lo} से {hi} pt तक होना चाहिए @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 से {MAX_BRIDGES_PER_ISLAND} तक पूरी संख्या होना चाहिए + Weed Box and Lines… वीड बॉक्स और लाइनें… + Weed Box and Lines वीड बॉक्स और लाइनें + Margin मार्जिन + Box बॉक्स + Swatch स्वॉच diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 5b8eec6b9f..948931ac13 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1158,6 +1158,17 @@ font-technology Variable Változó The selected character's font is not available. A kijelölt karakter betűtípusa nem érhető el. No alternates for the selected character. A kijelölt karakternek nincsenek változatai. Double-click to replace Csere dupla kattintással + Select one character with the Type tool first. Előbb jelölj ki egy karaktert a Szöveg eszközzel. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). A(z) {glyph} a(z) {font} glifája, nem a kijelölt szöveg betűtípusáé ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). A(z) {glyph} ehhez tartozik: {base} (U+{base_code}) {place}; a te kijelölésed: {sel} (U+{code}). + at the start of a word szó elején + in the middle of a word szó közepén + at the end of a word szó végén + standing alone önállóan állva + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. A(z) {glyph} nem változata a(z) {sel} (U+{code}) karakternek úgy, ahogy a szavában áll. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dupla kattintással ezt szúrod be helyette: {base} (U+{code}); a megjelenítési forma nem kapcsolódik, a betű igen. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. A megjelenítési forma helyett ez lett beszúrva: {base} (U+{code}); a betűtípus pozíció szerint kapcsolja a szomszédokhoz. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Itt egyetlen OpenType-funkció sem rajzolja meg ezt a glifát, ezért a karakter glifáját váltja fel, és előfordulhat, hogy nem kapcsolódik a szomszédos karakterekhez. Double-click to insert Beszúrás dupla kattintással Double-click to rename Dupla kattintással átnevezhető Double-click to target the contents Dupla kattintással a tartalom lesz a cél @@ -3508,6 +3519,7 @@ mark-style Japanese Japán Rotate View Tool Nézet elforgatása Trackpad Gesture to Rotate View Nézet elforgatása kézmozdulattal az érintőpadon Hold a Tool Shortcut to Use the Tool Temporarily Eszköz ideiglenes használata a billentyűparancsa nyomva tartásával + Show Bounding Box While Moving and Drawing Befoglaló keret megjelenítése mozgatás és rajzolás közben Rotate… Elforgatás… Rotation Elforgatás Rotation relative to: Elforgatás viszonyítási alapja: @@ -6998,3 +7010,8 @@ breakText Glyphs Glifák @msg Some islands could not be joined to the area around the art Egyes szigeteket nem sikerült összekötni a grafikát körülvevő területtel @msg `width` must be from {lo} to {hi} pt A `width` értéke {lo} és {hi} pt között lehet @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`: 1 és {MAX_BRIDGES_PER_ISLAND} közötti egész szám kell + Weed Box and Lines… Gyomláló keret és vonalak… + Weed Box and Lines Gyomláló keret és vonalak + Margin Margó + Box Keret + Swatch Színminta diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 21847bf54c..55e2e73bf1 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1335,6 +1335,7 @@ lighting Light Cahaya Rotate View Tool Alat putar tampilan Trackpad Gesture to Rotate View Putar tampilan dengan gestur trackpad Hold a Tool Shortcut to Use the Tool Temporarily Tahan pintasan alat untuk menggunakan alat itu sementara + Show Bounding Box While Moving and Drawing Tampilkan kotak pembatas saat memindahkan dan menggambar Rotate View Putar tampilan Rounded Rectangle Tool Alat persegi panjang bulat Scale Tool Alat skala @@ -1613,6 +1614,17 @@ digits Malayalam Malayalam The selected character's font is not available. Font karakter yang dipilih tidak tersedia. No alternates for the selected character. Tidak ada alternatif untuk karakter yang dipilih. Double-click to replace Klik ganda untuk mengganti + Select one character with the Type tool first. Pilih satu karakter dengan alat teks terlebih dahulu. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} adalah glif {font}, bukan dari font yang dipakai teks terpilih ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} milik {base} (U+{base_code}) {place}; pilihan Anda adalah {sel} (U+{code}). + at the start of a word di awal kata + in the middle of a word di tengah kata + at the end of a word di akhir kata + standing alone saat berdiri sendiri + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} bukan alternatif {sel} (U+{code}) sebagaimana posisinya di dalam katanya. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Klik ganda untuk menyisipkan {base} (U+{code}) sebagai gantinya: bentuk presentasi tidak menyambung, hurufnya menyambung. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) disisipkan sebagai ganti bentuk presentasi: font menyambungkannya ke huruf di sebelahnya menurut posisi. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Tidak ada fitur OpenType yang menggambar glif ini di sini, jadi glif ini menggantikan glif karakter dan mungkin tidak menyambung dengan karakter di sebelahnya. Double-click to insert Klik ganda untuk menyisipkan Entire Font Seluruh font glyph {id} glif {id} @@ -6706,4 +6718,9 @@ breakText Glyphs Glif Stencil Bridges… Jembatan Stensil… Stencil Bridges Jembatan Stensil Shortest Crossing Persilangan terpendek + Weed Box and Lines… Kotak dan garis pembersih sisa… + Weed Box and Lines Kotak dan garis pembersih sisa + Margin Margin + Box Kotak + Swatch Sampel warna Direction Arah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8778905d72..81dffc495f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -902,6 +902,17 @@ font-technology Variable Variabili The selected character's font is not available. Il font del carattere selezionato non è disponibile. No alternates for the selected character. Nessuna alternativa per il carattere selezionato. Double-click to replace Fai doppio clic per sostituire + Select one character with the Type tool first. Seleziona prima un carattere con lo strumento Testo. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} è un glifo di {font}, non del font del testo selezionato ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartiene a {base} (U+{base_code}) {place}; la tua selezione è {sel} (U+{code}). + at the start of a word all’inizio di una parola + in the middle of a word in mezzo a una parola + at the end of a word alla fine di una parola + standing alone da solo + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} non è un’alternativa di {sel} (U+{code}) così come si trova nella sua parola. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Fai doppio clic per inserire invece {base} (U+{code}): una forma di presentazione non si unisce, la lettera sì. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Inserito {base} (U+{code}) al posto di una forma di presentazione: il font lo unisce ai vicini in base alla posizione. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nessuna funzione OpenType disegna questo glifo qui: sostituisce il glifo del carattere e potrebbe non unirsi ai caratteri vicini. Double-click to insert Fai doppio clic per inserire Double-click to rename Fai doppio clic per rinominare Double-click to target the contents Fai doppio clic per impostare il contenuto come destinazione @@ -2975,6 +2986,7 @@ digits Malayalam Malayalam Rotate View Tool Strumento Ruota vista Trackpad Gesture to Rotate View Ruota vista con un gesto sul trackpad Hold a Tool Shortcut to Use the Tool Temporarily Tieni premuta la scorciatoia di uno strumento per usarlo temporaneamente + Show Bounding Box While Moving and Drawing Mostra riquadro di delimitazione durante lo spostamento e il disegno Rotate… Ruota… Rotation Rotazione Roughen… Irruvidisci… @@ -6643,3 +6655,8 @@ breakText Glyphs Glifi @msg Some islands could not be joined to the area around the art Non è stato possibile collegare alcune isole all’area intorno alla grafica @msg `width` must be from {lo} to {hi} pt `width` deve essere compreso tra {lo} e {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve essere un numero intero compreso tra 1 e {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Riquadro e linee di spellicolatura… + Weed Box and Lines Riquadro e linee di spellicolatura + Margin Margine + Box Riquadro + Swatch Campione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 20819ec6b2..9d8ec83078 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1540,6 +1540,17 @@ antiAlias Sharp シャープ The selected character's font is not available. 選択した文字のフォントを使用できません。 No alternates for the selected character. 選択した文字には異体字がありません。 Double-click to replace ダブルクリックで置換 + Select one character with the Type tool first. 先に文字ツールで文字を 1 つ選択してください。 + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} は {font} のグリフで、選択したテキストのフォント ({text_font}) のものではありません。 + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} は {base} (U+{base_code}) の{place}のグリフです。選択中の文字は {sel} (U+{code}) です。 + at the start of a word 語頭 + in the middle of a word 語中 + at the end of a word 語末 + standing alone 単独のとき + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} は、{sel} (U+{code}) が単語の中で置かれている位置での異体字ではありません。 + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ダブルクリックで代わりに {base} (U+{code}) を挿入: 表示形は前後とつながりませんが、文字はつながります。 + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 表示形の代わりに {base} (U+{code}) を挿入しました。フォントが位置に応じて前後の文字とつなげます。 + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. このグリフを表示する OpenType フィーチャーがないため、文字のグリフが置き換わり、前後の文字とつながらないことがあります。 Double-click to insert ダブルクリックで挿入 Double-click to rename ダブルクリックで名前を変更 Double-click to target the contents ダブルクリックで内容をターゲット @@ -2962,6 +2973,7 @@ mark-style Japanese 日本式 Rotate View Tool 回転ビューツール Trackpad Gesture to Rotate View トラックパッドのジェスチャーでビューを回転 Hold a Tool Shortcut to Use the Tool Temporarily ツールのショートカットを押している間だけツールを使用 + Show Bounding Box While Moving and Drawing 移動中と描画中にバウンディングボックスを表示 Rotation 回転 Round Cap 丸型線端 Round Join ラウンド結合 @@ -6708,3 +6720,8 @@ breakText Glyphs 字形 @msg Some islands could not be joined to the area around the art 一部のアイランドをアートワークの周囲の領域につなげられませんでした @msg `width` must be from {lo} to {hi} pt `width` は {lo}~{hi} pt の範囲で指定してください @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` には 1 から {MAX_BRIDGES_PER_ISLAND} の整数を指定してください + Weed Box and Lines… カス取り枠とライン… + Weed Box and Lines カス取り枠とライン + Margin マージン + Box 枠 + Swatch スウォッチ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c6699791fa..2630b64269 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1552,6 +1552,17 @@ digits Malayalam მალაიალამური The selected character's font is not available. მონიშნული სიმბოლოს შრიფტი მიუწვდომელია. No alternates for the selected character. მონიშნულ სიმბოლოს ალტერნატივები არ აქვს. Double-click to replace ორჯერ დაწკაპეთ ჩასანაცვლებლად + Select one character with the Type tool first. ჯერ ტექსტის ხელსაწყოთი მონიშნეთ ერთი სიმბოლო. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} არის {font}-ის გლიფი და არა იმ შრიფტისა, რომლითაც მონიშნული ტექსტია დაწყობილი ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} ეკუთვნის სიმბოლოს {base} (U+{base_code}) {place}; თქვენი მონიშნულია {sel} (U+{code}). + at the start of a word სიტყვის დასაწყისში + in the middle of a word სიტყვის შუაში + at the end of a word სიტყვის ბოლოს + standing alone როცა ცალკე დგას + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} არ არის {sel} (U+{code})-ის ალტერნატივა იმ სახით, როგორც ის თავის სიტყვაშია. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ორჯერ დაწკაპეთ ამის ნაცვლად {base} (U+{code}) ჩასასმელად: გამოსახვის ფორმა არ უკავშირდება, ასო უკავშირდება. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. გამოსახვის ფორმის ნაცვლად ჩასმულია {base} (U+{code}): შრიფტი მას მეზობლებს პოზიციის მიხედვით აკავშირებს. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. აქ არც ერთი OpenType ფუნქცია არ ხატავს ამ გლიფს, ამიტომ ის ანაცვლებს სიმბოლოს გლიფს და შეიძლება არ დაუკავშირდეს მეზობელ სიმბოლოებს. Double-click to insert ორჯერ დაწკაპეთ ჩასასმელად Entire Font მთელი შრიფტი glyph {id} გლიფი {id} @@ -5835,6 +5846,7 @@ imageTrace Outline კონტური Thread Text ტექსტის დაკავშირება Trackpad Gesture to Rotate View ხედის შემობრუნება ტრეკპადის ჟესტით Hold a Tool Shortcut to Use the Tool Temporarily ხელსაწყოს დროებით გამოყენება მისი მალსახმობის დაჭერით + Show Bounding Box While Moving and Drawing შემომსაზღვრელი ჩარჩოს ჩვენება გადაადგილებისა და ხატვისას Typing აკრეფა Uniformly Scaled თანაბრად მასშტაბირებული Unlock Variables ცვლადების განბლოკვა @@ -7128,3 +7140,8 @@ font-script Japanese იაპონური @msg Some islands could not be joined to the area around the art ზოგიერთი კუნძული ვერ დაუკავშირდა ნახატის გარშემო არეს @msg `width` must be from {lo} to {hi} pt `width` უნდა იყოს {lo}-დან {hi} pt-მდე @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` უნდა იყოს მთელი რიცხვი 1-დან {MAX_BRIDGES_PER_ISLAND}-მდე + Weed Box and Lines… ნარჩენის მოსაცილებელი ჩარჩო და ხაზები… + Weed Box and Lines ნარჩენის მოსაცილებელი ჩარჩო და ხაზები + Margin ზღვარი + Box ჩარჩო + Swatch ფერის ნიმუში diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b22d40146b..30ac7dfb76 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1530,6 +1530,17 @@ antiAlias Sharp 날카롭게 The selected character's font is not available. 선택한 문자의 글꼴을 사용할 수 없습니다. No alternates for the selected character. 선택한 문자의 대체 글리프가 없습니다. Double-click to replace 더블클릭하여 바꾸기 + Select one character with the Type tool first. 먼저 문자 도구로 문자 하나를 선택하세요. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}은(는) {font}의 글리프이며, 선택한 텍스트의 글꼴({text_font})의 것이 아닙니다. + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}은(는) {base}(U+{base_code})의 {place} 글리프입니다. 선택한 문자는 {sel}(U+{code})입니다. + at the start of a word 단어 처음 + in the middle of a word 단어 중간 + at the end of a word 단어 끝 + standing alone 단독일 때 + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}은(는) 단어 안에 있는 {sel}(U+{code})의 대체 글리프가 아닙니다. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 더블클릭하여 대신 {base}(U+{code}) 삽입: 표시 형태는 연결되지 않지만 글자는 연결됩니다. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 표시 형태 대신 {base}(U+{code})을(를) 삽입했습니다. 글꼴이 위치에 따라 이웃 문자와 연결합니다. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 이 글리프를 그리는 OpenType 기능이 없으므로 문자의 글리프를 대체하며 이웃 문자와 연결되지 않을 수 있습니다. Double-click to insert 더블클릭하여 삽입 Double-click to rename 더블클릭하여 이름 변경 Double-click to target the contents 더블클릭하여 내용 선택 @@ -2951,6 +2962,7 @@ mark-style Japanese 일본식 Rotate View Tool 보기 회전 도구 Trackpad Gesture to Rotate View 트랙패드 제스처로 보기 회전 Hold a Tool Shortcut to Use the Tool Temporarily 도구 단축키를 누르고 있는 동안 도구를 임시로 사용 + Show Bounding Box While Moving and Drawing 이동 및 그리기 중 경계 상자 표시 Rotation 회전 Round Cap 둥근 끝 Round Join 둥근 연결 @@ -6604,3 +6616,8 @@ breakText Glyphs 글리프 @msg Some islands could not be joined to the area around the art 일부 섬을 아트워크 주변 영역에 연결할 수 없습니다 @msg `width` must be from {lo} to {hi} pt `width`는 {lo}에서 {hi}pt 사이여야 합니다 @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`는 1에서 {MAX_BRIDGES_PER_ISLAND} 사이의 정수여야 합니다 + Weed Box and Lines… 위딩 상자 및 선… + Weed Box and Lines 위딩 상자 및 선 + Margin 여백 + Box 상자 + Swatch 견본 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 707853f033..57ca80dc20 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1349,6 +1349,7 @@ lighting Light Licht Rotate View Tool Weergave draaien-gereedschap Trackpad Gesture to Rotate View Weergave draaien met trackpadgebaar Hold a Tool Shortcut to Use the Tool Temporarily Sneltoets van een gereedschap ingedrukt houden om het tijdelijk te gebruiken + Show Bounding Box While Moving and Drawing Begrenzingskader tonen tijdens verplaatsen en tekenen Rotate View Weergave draaien Rounded Rectangle Tool Afgeronde rechthoek-gereedschap Scale Tool Schalen-gereedschap @@ -1627,6 +1628,17 @@ digits Malayalam Malayalam The selected character's font is not available. Het lettertype van het geselecteerde teken is niet beschikbaar. No alternates for the selected character. Geen alternatieven voor het geselecteerde teken. Double-click to replace Dubbelklik om te vervangen + Select one character with the Type tool first. Selecteer eerst één teken met het Type-gereedschap. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} is een glyph van {font}, niet van het lettertype van de geselecteerde tekst ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hoort bij {base} (U+{base_code}) {place}; uw selectie is {sel} (U+{code}). + at the start of a word aan het begin van een woord + in the middle of a word midden in een woord + at the end of a word aan het einde van een woord + standing alone los staand + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} is geen alternatief voor {sel} (U+{code}) zoals het in zijn woord staat. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklik om in plaats daarvan {base} (U+{code}) in te voegen: een presentatievorm sluit niet aan, de letter wel. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) ingevoegd in plaats van een presentatievorm: het lettertype verbindt het naar positie met de buren. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Geen enkele OpenType-functie tekent deze glyph hier, dus hij vervangt de glyph van het teken en sluit mogelijk niet aan op de buurtekens. Double-click to insert Dubbelklik om in te voegen Entire Font Volledig lettertype glyph {id} glyph {id} @@ -6757,4 +6769,9 @@ breakText Glyphs Glyphs Stencil Bridges… Sjabloonbruggen… Stencil Bridges Sjabloonbruggen Shortest Crossing Kortste oversteek + Weed Box and Lines… Wiedkader en wiedlijnen… + Weed Box and Lines Wiedkader en wiedlijnen + Margin Marge + Box Kader + Swatch Staal Direction Richting diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 62525254f3..4094d29879 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1347,6 +1347,7 @@ lighting Light Światło Rotate View Tool Narzędzie Obrót widoku Trackpad Gesture to Rotate View Obracaj widok gestem na gładziku Hold a Tool Shortcut to Use the Tool Temporarily Używaj narzędzia tymczasowo, przytrzymując jego skrót + Show Bounding Box While Moving and Drawing Pokaż obwiednię podczas przesuwania i rysowania Rounded Rectangle Tool Narzędzie Zaokrąglony prostokąt Scale Tool Narzędzie Skalowanie Scallop Tool Narzędzie Ząbkowanie @@ -1619,6 +1620,17 @@ digits Malayalam Malajalam The selected character's font is not available. Czcionka zaznaczonego znaku jest niedostępna. No alternates for the selected character. Brak alternatyw dla zaznaczonego znaku. Double-click to replace Kliknij dwukrotnie, aby zastąpić + Select one character with the Type tool first. Najpierw zaznacz jeden znak narzędziem Tekst. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} to glif czcionki {font}, a nie czcionki zaznaczonego tekstu ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} należy do znaku {base} (U+{base_code}) {place}; Twoje zaznaczenie to {sel} (U+{code}). + at the start of a word na początku wyrazu + in the middle of a word w środku wyrazu + at the end of a word na końcu wyrazu + standing alone gdy stoi osobno + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nie jest alternatywą znaku {sel} (U+{code}) w takiej postaci, w jakiej stoi w swoim wyrazie. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Kliknij dwukrotnie, aby wstawić zamiast tego {base} (U+{code}): forma prezentacyjna się nie łączy, litera tak. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Wstawiono {base} (U+{code}) zamiast formy prezentacyjnej: czcionka łączy go z sąsiadami zależnie od pozycji. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Żadna funkcja OpenType nie rysuje tutaj tego glifu, więc zastępuje on glif znaku i może nie łączyć się z sąsiednimi znakami. Double-click to insert Kliknij dwukrotnie, aby wstawić Entire Font Cała czcionka glyph {id} glif {id} @@ -6870,3 +6882,8 @@ breakText Glyphs Glify @msg Some islands could not be joined to the area around the art Niektórych wysp nie udało się połączyć z obszarem wokół grafiki @msg `width` must be from {lo} to {hi} pt `width` musi mieścić się w zakresie od {lo} do {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musi być liczbą całkowitą od 1 do {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Ramka i linie do wybierania… + Weed Box and Lines Ramka i linie do wybierania + Margin Margines + Box Ramka + Swatch Próbka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7d85d7d3d9..a367880be2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -804,6 +804,17 @@ antiAlias Sharp Nítido The selected character's font is not available. A fonte do caractere selecionado não está disponível. No alternates for the selected character. Nenhuma alternativa para o caractere selecionado. Double-click to replace Clique duplo para substituir + Select one character with the Type tool first. Primeiro selecione um caractere com a ferramenta Texto. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} é um glifo de {font}, não da fonte do texto selecionado ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertence a {base} (U+{base_code}) {place}; sua seleção é {sel} (U+{code}). + at the start of a word no início de uma palavra + in the middle of a word no meio de uma palavra + at the end of a word no fim de uma palavra + standing alone quando está sozinho + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} não é uma alternativa de {sel} (U+{code}) como ele está em sua palavra. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Clique duplo para inserir {base} (U+{code}) no lugar: uma forma de apresentação não se une, a letra sim. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) foi inserido no lugar de uma forma de apresentação: a fonte o une aos vizinhos conforme a posição. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nenhum recurso OpenType desenha este glifo aqui, então ele substitui o glifo do caractere e pode não se unir aos caracteres vizinhos. Double-click to insert Clique duplo para inserir Double-click to rename Clique duplo para renomear Double-click to target the contents Clique duplo para direcionar o conteúdo @@ -2461,6 +2472,7 @@ digits Malayalam Malaiala Rotate View Tool Ferramenta Girar Visualização Trackpad Gesture to Rotate View Girar Visualização com Gesto do Trackpad Hold a Tool Shortcut to Use the Tool Temporarily Manter o Atalho de uma Ferramenta Pressionado para Usá-la Temporariamente + Show Bounding Box While Moving and Drawing Mostrar Caixa Delimitadora ao Mover e Desenhar Rotate… Girar… Rotation Rotação Roughen… Enrugar… @@ -7087,3 +7099,8 @@ swatch Yellow Green Verde-amarelado @msg Some islands could not be joined to the area around the art Algumas ilhas não puderam ser unidas à área ao redor da arte @msg `width` must be from {lo} to {hi} pt `width` deve estar entre {lo} e {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve ser um número inteiro de 1 a {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Caixa e Linhas de Descasque… + Weed Box and Lines Caixa e Linhas de Descasque + Margin Margem + Box Caixa + Swatch Amostra diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f157900423..e03cc555b1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -892,6 +892,17 @@ font-technology Variable Вариативные The selected character's font is not available. Шрифт выделенного символа недоступен. No alternates for the selected character. У выделенного символа нет альтернатив. Double-click to replace Дважды щёлкните для замены + Select one character with the Type tool first. Сначала выделите один символ инструментом «Текст». + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — глиф шрифта {font}, а не шрифта выделенного текста ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} относится к символу {base} (U+{base_code}) {place}; выделен символ {sel} (U+{code}). + at the start of a word в начале слова + in the middle of a word в середине слова + at the end of a word в конце слова + standing alone в отдельном виде + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не является альтернативой символа {sel} (U+{code}) в том виде, как он стоит в своём слове. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Дважды щёлкните, чтобы вставить вместо него {base} (U+{code}): форма представления не соединяется, буква соединяется. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вместо формы представления вставлено {base} (U+{code}): шрифт соединяет его с соседними по положению. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ни одна функция OpenType не рисует здесь этот глиф, поэтому он заменяет глиф символа и может не соединяться с соседними символами. Double-click to insert Дважды щёлкните для вставки Double-click to rename Дважды щёлкните для переименования Double-click to target the contents Дважды щёлкните, чтобы выбрать содержимое как объект @@ -2962,6 +2973,7 @@ digits Malayalam Малаялам Rotate View Tool Инструмент «Поворот вида» Trackpad Gesture to Rotate View Поворачивать вид жестом трекпада Hold a Tool Shortcut to Use the Tool Temporarily Временно использовать инструмент, удерживая его клавишу + Show Bounding Box While Moving and Drawing Показывать ограничительную рамку при перемещении и рисовании Rotate… Повернуть… Rotation Поворот Roughen… Огрубление… @@ -7103,3 +7115,8 @@ shape Open Открытый @msg Some islands could not be joined to the area around the art Некоторые островки не удалось соединить с областью вокруг графики @msg `width` must be from {lo} to {hi} pt `width` должно быть от {lo} до {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` должно быть целым числом от 1 до {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Рамка и линии выборки… + Weed Box and Lines Рамка и линии выборки + Margin Поле + Box Рамка + Swatch Образец diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4c09ba860d..83a29c8531 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -881,6 +881,17 @@ font-technology Variable Variabla The selected character's font is not available. Typsnittet för det markerade tecknet är inte tillgängligt. No alternates for the selected character. Inga alternativ för det markerade tecknet. Double-click to replace Dubbelklicka för att ersätta + Select one character with the Type tool first. Markera först ett tecken med textverktyget. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} är en glyf i {font}, inte i det typsnitt den markerade texten är satt i ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hör till {base} (U+{base_code}) {place}; din markering är {sel} (U+{code}). + at the start of a word i början av ett ord + in the middle of a word mitt i ett ord + at the end of a word i slutet av ett ord + standing alone när det står ensamt + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} är inget alternativ till {sel} (U+{code}) som det står i sitt ord. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklicka för att infoga {base} (U+{code}) i stället: en presentationsform ansluter inte, bokstaven gör det. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) infogades i stället för en presentationsform: typsnittet ansluter det till grannarna efter position. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion ritar den här glyfen här, så den ersätter tecknets glyf och ansluter kanske inte till grannteckenen. Double-click to insert Dubbelklicka för att infoga Double-click to rename Dubbelklicka för att byta namn Double-click to target the contents Dubbelklicka för att välja innehållet som mål @@ -2868,6 +2879,7 @@ digits Malayalam Malayalam Rotate View Tool Verktyget Rotera vy Trackpad Gesture to Rotate View Rotera vyn med en gest på styrplattan Hold a Tool Shortcut to Use the Tool Temporarily Håll ned ett verktygs kortkommando för att använda verktyget tillfälligt + Show Bounding Box While Moving and Drawing Visa begränsningsram vid flyttning och ritning Rotate… Rotera… Rotation Rotation Roughen… Ojämn… @@ -8383,7 +8395,7 @@ digits Hindi Hindi @msg width must be positive width måste vara positiv @msg width out of range bredden ligger utanför tillåtet omfång @msg width/height must be > 0 bredd/höjd måste vara > 0 -@msg width/height must be positive and within the canvas bredd/höjd måste vara positiva och inom arbetsytan +@msg width and height must be more than 0 and at most {limit} bredd och höjd måste vara större än 0 och högst {limit} @msg x and y must be finite numbers x och y måste vara ändliga tal @msg x and y must be given together x och y måste anges tillsammans @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone behöver ett ankare: docka bredvid en panel med anchor eller i en ny dockkolumn med side (Start eller End) @@ -8492,3 +8504,8 @@ digits Hindi Hindi @msg Some islands could not be joined to the area around the art Vissa öar kunde inte förbindas med området runt teckningen @msg `width` must be from {lo} to {hi} pt `width` måste vara från {lo} till {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` måste vara ett heltal från 1 till {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Rensningsram och rensningslinjer… + Weed Box and Lines Rensningsram och rensningslinjer + Margin Marginal + Box Ram + Swatch Färgruta diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8d6e163398..c781b80ebb 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -873,6 +873,17 @@ font-technology Variable Değişken The selected character's font is not available. Seçili karakterin yazı tipi kullanılamıyor. No alternates for the selected character. Seçili karakter için alternatif yok. Double-click to replace Değiştirmek için çift tıklayın + Select one character with the Type tool first. Önce Yazım aracıyla bir karakter seçin. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}, seçili metnin yazı tipine ({text_font}) değil, {font} yazı tipine ait bir glif. + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}, {base} (U+{base_code}) karakterine aittir ({place}); seçiminiz {sel} (U+{code}). + at the start of a word kelimenin başında + in the middle of a word kelimenin ortasında + at the end of a word kelimenin sonunda + standing alone tek başına dururken + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, kendi kelimesinde durduğu hâliyle {sel} (U+{code}) karakterinin alternatifi değil. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bunun yerine {base} (U+{code}) eklemek için çift tıklayın: sunum biçimi bağlanmaz, harf bağlanır. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Sunum biçimi yerine {base} (U+{code}) eklendi: yazı tipi onu konumuna göre komşularına bağlar. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Hiçbir OpenType özelliği bu glifi burada çizmez; bu yüzden karakterin glifinin yerine geçer ve komşu karakterlere bağlanmayabilir. Double-click to insert Eklemek için çift tıklayın Double-click to rename Yeniden adlandırmak için çift tıklayın Double-click to target the contents İçeriği hedeflemek için çift tıklayın @@ -5722,6 +5733,7 @@ theme Appearance Görünüm Thread Text Metni Bağla Trackpad Gesture to Rotate View Görünümü Döndürmek İçin İzleme Dörtgeni Hareketi Hold a Tool Shortcut to Use the Tool Temporarily Aracı Geçici Olarak Kullanmak İçin Kısayolunu Basılı Tutma + Show Bounding Box While Moving and Drawing Taşırken ve Çizerken Sınırlayıcı Kutuyu Göster Typing Yazma Uniformly Scaled Tekdüze Ölçeklenmiş Unlock Variables Değişkenlerin Kilidini Aç @@ -6970,3 +6982,8 @@ breakText Glyphs Glifler @msg Some islands could not be joined to the area around the art Bazı adalar çizimin çevresindeki alana bağlanamadı @msg `width` must be from {lo} to {hi} pt `width` {lo} ile {hi} pt arasında olmalıdır @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 ile {MAX_BRIDGES_PER_ISLAND} arasında bir tam sayı olmalıdır + Weed Box and Lines… Ayıklama Kutusu ve Çizgileri… + Weed Box and Lines Ayıklama Kutusu ve Çizgileri + Margin Kenar Boşluğu + Box Kutu + Swatch Renk Örneği diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3eac6dc1a1..75e062627c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -896,6 +896,17 @@ font-technology Variable Варіативні The selected character's font is not available. Шрифт виділеного символу недоступний. No alternates for the selected character. Виділений символ не має альтернатив. Double-click to replace Двічі клацніть для заміни + Select one character with the Type tool first. Спершу виділіть один символ інструментом «Текст». + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — гліф шрифту {font}, а не шрифту виділеного тексту ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} належить символу {base} (U+{base_code}) {place}; виділено символ {sel} (U+{code}). + at the start of a word на початку слова + in the middle of a word у середині слова + at the end of a word наприкінці слова + standing alone в окремому вигляді + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не є альтернативою символу {sel} (U+{code}) у тому вигляді, в якому він стоїть у своєму слові. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двічі клацніть, щоб вставити замість нього {base} (U+{code}): форма подання не з’єднується, літера з’єднується. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Замість форми подання вставлено {base} (U+{code}): шрифт з’єднує його із сусідніми за положенням. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Жодна функція OpenType не малює тут цей гліф, тому він замінює гліф символу й може не з’єднуватися із сусідніми символами. Double-click to insert Двічі клацніть для вставки Double-click to rename Двічі клацніть для перейменування Double-click to target the contents Двічі клацніть, щоб вибрати вміст як об’єкт @@ -2967,6 +2978,7 @@ digits Malayalam Малаялам Rotate View Tool Інструмент «Поворот виду» Trackpad Gesture to Rotate View Повертати вид жестом трекпада Hold a Tool Shortcut to Use the Tool Temporarily Тимчасово використовувати інструмент, утримуючи його клавішу + Show Bounding Box While Moving and Drawing Показувати обмежувальну рамку під час переміщення та малювання Rotate… Повернути… Rotation Поворот Roughen… Надати нерівності… @@ -6623,3 +6635,8 @@ breakText Glyphs Гліфи @msg Some islands could not be joined to the area around the art Деякі острівці не вдалося з’єднати з областю навколо графіки @msg `width` must be from {lo} to {hi} pt `width` має бути від {lo} до {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` має бути цілим числом від 1 до {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Рамка й лінії вибірки… + Weed Box and Lines Рамка й лінії вибірки + Margin Поле + Box Рамка + Swatch Зразок diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index da1c2adba2..7df0386f9e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1051,6 +1051,17 @@ font-technology Variable Biến thiên The selected character's font is not available. Phông chữ của ký tự đã chọn không khả dụng. No alternates for the selected character. Ký tự đã chọn không có glyph thay thế. Double-click to replace Bấm đúp để thay thế + Select one character with the Type tool first. Trước hết hãy chọn một ký tự bằng công cụ Chữ. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} là glyph của {font}, không phải của phông chữ mà văn bản đã chọn đang dùng ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} thuộc về {base} (U+{base_code}) {place}; ký tự bạn chọn là {sel} (U+{code}). + at the start of a word ở đầu từ + in the middle of a word ở giữa từ + at the end of a word ở cuối từ + standing alone khi đứng riêng + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} không phải glyph thay thế của {sel} (U+{code}) như ký tự đó đang đứng trong từ của nó. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bấm đúp để chèn {base} (U+{code}) thay thế: dạng trình bày không nối, còn chữ cái thì nối. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Đã chèn {base} (U+{code}) thay cho dạng trình bày: phông chữ nối nó với các ký tự bên cạnh theo vị trí. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Không có tính năng OpenType nào vẽ glyph này ở đây, nên nó thay glyph của ký tự và có thể không nối với các ký tự bên cạnh. Double-click to insert Bấm đúp để chèn Double-click to rename Bấm đúp để đổi tên Double-click to target the contents Bấm đúp để chọn nội dung @@ -3271,6 +3282,7 @@ mark-style Japanese Kiểu Nhật Rotate View Tool Công cụ Xoay chế độ xem Trackpad Gesture to Rotate View Xoay chế độ xem bằng cử chỉ trên bàn di chuột Hold a Tool Shortcut to Use the Tool Temporarily Giữ phím tắt của công cụ để tạm thời dùng công cụ đó + Show Bounding Box While Moving and Drawing Hiện hộp bao khi di chuyển và vẽ Rotate… Xoay… Rotation Góc xoay Rotation relative to: Xoay tương đối với: @@ -6624,3 +6636,8 @@ breakText Glyphs Glyph @msg Some islands could not be joined to the area around the art Không thể nối một số đảo với vùng xung quanh hình vẽ @msg `width` must be from {lo} to {hi} pt `width` phải từ {lo} đến {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` phải là số nguyên từ 1 đến {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Khung và đường bóc phần thừa… + Weed Box and Lines Khung và đường bóc phần thừa + Margin Lề + Box Khung + Swatch Mẫu màu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8c5f12bde6..9b593188b9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -836,6 +836,17 @@ antiAlias Sharp 锐利 The selected character's font is not available. 所选字符的字体不可用。 No alternates for the selected character. 所选字符没有替代字。 Double-click to replace 双击以替换 + Select one character with the Type tool first. 请先用文字工具选择一个字符。 + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所选文本所用字体({text_font})的字形。 + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 属于 {base}(U+{base_code}){place};您选择的是 {sel}(U+{code})。 + at the start of a word 位于词首 + in the middle of a word 位于词中 + at the end of a word 位于词尾 + standing alone 单独出现时 + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其单词中所处位置的替代字。 + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 双击以改为插入 {base}(U+{code}):表现形式不会连接,字母会连接。 + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以代替表现形式:字体会按位置将其与相邻字符连接。 + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 没有任何 OpenType 功能会在此处绘制该字形,因此它会替换该字符的字形,并且可能无法与相邻字符连接。 Double-click to insert 双击以插入 Double-click to rename 双击以重命名 Double-click to target the contents 双击以指定内容 @@ -2831,6 +2842,7 @@ digits Malayalam 马拉雅拉姆数字 Rotate View Tool 旋转视图工具 Trackpad Gesture to Rotate View 使用触控板手势旋转视图 Hold a Tool Shortcut to Use the Tool Temporarily 按住工具快捷键以临时使用该工具 + Show Bounding Box While Moving and Drawing 移动和绘制时显示定界框 Rotate… 旋转… Rotation 旋转 Roughen… 粗糙效果… @@ -6852,3 +6864,8 @@ breakText Glyphs 字形 @msg Some islands could not be joined to the area around the art 部分孤岛无法连接到图稿周围的区域 @msg `width` must be from {lo} to {hi} pt `width` 必须介于 {lo} 到 {hi} pt 之间 @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必须是 1 到 {MAX_BRIDGES_PER_ISLAND} 之间的整数 + Weed Box and Lines… 除废框和除废线… + Weed Box and Lines 除废框和除废线 + Margin 边距 + Box 框 + Swatch 色板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index aa00db1430..d2245176dd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -864,6 +864,17 @@ font-technology Variable 可變 The selected character's font is not available. 選取字元的字體無法使用。 No alternates for the selected character. 選取的字元沒有替代字。 Double-click to replace 按兩下以取代 + Select one character with the Type tool first. 請先用文字工具選取一個字元。 + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所選文字所用字體({text_font})的字形。 + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 屬於 {base}(U+{base_code}){place};您選取的是 {sel}(U+{code})。 + at the start of a word 位於詞首 + in the middle of a word 位於詞中 + at the end of a word 位於詞尾 + standing alone 單獨出現時 + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其單字中所處位置的替代字。 + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 按兩下以改為插入 {base}(U+{code}):表現形式不會連接,字母會連接。 + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以取代表現形式:字體會依位置將其與相鄰字元連接。 + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 沒有任何 OpenType 功能會在此處繪製該字形,因此它會取代該字元的字形,且可能無法與相鄰字元連接。 Double-click to insert 按兩下以插入 Double-click to rename 按兩下以重新命名 Double-click to target the contents 按兩下以指定內容 @@ -3943,6 +3954,7 @@ digits Malayalam 馬來亞拉姆數字 Rotate View Tool 旋轉檢視工具 Trackpad Gesture to Rotate View 使用觸控式軌跡板手勢旋轉檢視 Hold a Tool Shortcut to Use the Tool Temporarily 按住工具快速鍵以暫時使用該工具 + Show Bounding Box While Moving and Drawing 移動和繪製時顯示邊框 Rotate… 旋轉… Rotation 旋轉 Roughen… 粗糙效果… @@ -6658,3 +6670,8 @@ breakText Glyphs 字符 @msg Some islands could not be joined to the area around the art 部分孤島無法連接到圖稿周圍的區域 @msg `width` must be from {lo} to {hi} pt `width` 必須介於 {lo} 到 {hi} pt 之間 @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必須是介於 1 到 {MAX_BRIDGES_PER_ISLAND} 的整數 + Weed Box and Lines… 除廢框與除廢線… + Weed Box and Lines 除廢框與除廢線 + Margin 邊距 + Box 框 + Swatch 色票 diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index ab11de0abc..69a5f54eff 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -300,7 +300,9 @@ pub(super) fn save_suggesting( if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { return ask_format_options(app, mode, first.format, &path); } - return write_plan(app, first, (save_command(mode), with_path(p, &path)), None); + // Only `path` was asked about: the files written beside it are checked against the file + // the document was read from when the save writes them. + return write_plan(app, plan(app, mode, p)?, (save_command(mode), with_path(p, &path)), None); } if is_web(app) && ask_options { return Ok(open_options(app, mode.command(), first.format, &first.name, true)); @@ -337,6 +339,7 @@ pub(super) fn save_suggesting( crate::dialogs::opened_by_panel(app); return r; } + let chosen = plan(app, mode, &q)?; write_plan(app, chosen, (save_command(mode), q), None) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1c64655f73..b5964fe274 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -197,6 +197,10 @@ pub struct VectorcraftApp { frame_rate: frame_rate::FrameRate, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. pub canvas_rect: Option, + /// The UI zoom (egui's zoom factor, set by Preferences › User Interface › UI Scaling) of the + /// frame that laid out [`Self::canvas_rect`]; 1.0 before the first one. [`Self::canvas_xf`] + /// maps the document to that rect with it. + pub(crate) canvas_ui_zoom: f32, /// An unfinished control-channel Rotate View gesture, kept only for this app instance. pub(crate) control_rotate_press: Option, /// Hover position in document coordinates. diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 62ced42660..390f168659 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -405,7 +405,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "view.actualSize", "Actual Size", "Cmd+1", - "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling); the same at every UI Scaling", ), ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), ( @@ -1372,25 +1372,25 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::RENAME_ACTION, "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}", ), ( crate::panels::actions::INCLUDE_STEPS, "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}", ), ( crate::panels::actions::NEW_ACTION, "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}", ), ( crate::panels::actions::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}", ), ( crate::panels::actions::PLAY_ACTION, @@ -1408,13 +1408,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::START_RECORDING, "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}", ), ( crate::panels::actions::STOP_RECORDING, "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}", ), ( crate::panels::actions::INSERT_STOP, @@ -1456,7 +1456,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::LOAD_SET, "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\"", ), ( crate::panels::actions::DELETE_SETS, @@ -1468,7 +1468,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::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/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index de6b36bc6e..f2426f33ab 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -54,13 +54,6 @@ impl Action { (0..self.steps.len()).all(|i| !self.excluded_steps.contains(&i)) } - fn set_steps_included(&mut self, included: bool) { - self.excluded_steps.clear(); - if !included { - self.excluded_steps.extend(0..self.steps.len()); - } - } - /// Puts `step` at index `at`; the steps from there on keep whether they are excluded. An /// exclusion past the last step, which saved preferences can hold, goes. fn insert_step(&mut self, at: usize, step: (String, Value)) { @@ -373,7 +366,8 @@ struct AtStop { } /// Takes the stop at the front of the playback's steps and waits there. A recording in progress -/// records the stop too. +/// records the stop too, unless the stop does not fit in its action ([`recording_room`]): then the +/// status bar shows the reason. fn hold_at_stop(app: &mut VectorcraftApp) { let Some(playing) = app.ui.action_playing.as_mut() else { return }; let Some(step) = playing.steps.pop_front() else { return }; @@ -383,7 +377,10 @@ fn hold_at_stop(app: &mut VectorcraftApp) { playing.stop = Some(AtStop { message, allow_continue, shown: false }); playing.hold = false; if app.ui.recording.is_some() { - app.session.journal.push((STOP_STEP.to_string(), params)); + match recording_room(app, STOP_STEP, ¶ms) { + Ok(()) => app.session.journal.push((STOP_STEP.to_string(), params)), + Err(e) => app.status(e), + } } show_stop_message(app); } @@ -621,7 +618,10 @@ pub const INCLUDE_STEPS: &str = "ui.action.stepsIncluded"; /// `{set, action?, step?, included, others?}` includes or excludes all steps of a set, all steps /// of one action, or one step. With `step` and `others: true`, leaves that step as it was and -/// applies `included` to the other steps of its action. Invalid indexes change nothing. +/// applies `included` to the other steps of its action. Invalid indexes change nothing. So does an +/// exclusion that would give the set a file larger than [`files::MAX_SET_FILE`], the most Load +/// Actions… reads, counting the steps that 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. pub fn include_steps(app: &mut VectorcraftApp, p: &Value) -> Result { let index = |key: &str| p.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()); let set = index("set").ok_or("no such action set")?; @@ -636,34 +636,53 @@ pub fn include_steps(app: &mut VectorcraftApp, p: &Value) -> Result { - for action in &mut set_ref.actions { - action.set_steps_included(included); - } - } - (Some(ai), None) => set_ref.actions.get_mut(ai).ok_or("no such action")?.set_steps_included(included), - (Some(ai), Some(si)) => { - let action = set_ref.actions.get_mut(ai).ok_or("no such action")?; - if si >= action.steps.len() { - return Err("no such action step".into()); - } - if others { - let was_excluded = action.excluded_steps.contains(&si); - action.set_steps_included(included); - if was_excluded { - action.excluded_steps.insert(si); + let set_ref = app.ui.action_sets.get(set).ok_or("no such action set")?; + let actions = match action { + None => 0..set_ref.actions.len(), + Some(ai) if ai < set_ref.actions.len() => ai..ai + 1, + Some(_) => return Err("no such action".into()), + }; + if let (Some(ai), Some(si)) = (action, step) + && set_ref.actions.get(ai).is_none_or(|a| si >= a.steps.len()) + { + return Err("no such action step".into()); + } + // The exclusions each action gets, worked out before any of them changes. + let every = |a: &Action| if included { BTreeSet::new() } else { (0..a.steps.len()).collect() }; + let changes: Vec<(usize, BTreeSet)> = actions + .map(|ai| { + let a = &set_ref.actions[ai]; + let mut excluded = match step { + Some(_) if !others => a.excluded_steps.clone(), + _ => every(a), + }; + if let Some(si) = step { + let keep_excluded = if others { a.excluded_steps.contains(&si) } else { !included }; + if keep_excluded { + excluded.insert(si); } else { - action.excluded_steps.remove(&si); + excluded.remove(&si); } - } else if included { - action.excluded_steps.remove(&si); - } else { - action.excluded_steps.insert(si); } + (ai, excluded) + }) + .collect(); + // Each excluded step takes [`files::EXCLUDED_BYTES`] in the file; only exclusions past the + // ones the set has now can make it too large. + let (mut now, mut then) = (0, 0); + for (ai, excluded) in &changes { + let a = &set_ref.actions[*ai]; + now += a.excluded_steps.range(..a.steps.len()).count(); + then += excluded.range(..a.steps.len()).count(); + } + if then > now && set_file_len(app, set) + (then - now) * files::EXCLUDED_BYTES > files::MAX_SET_FILE { + return Err("the action set would be larger than 8 MiB".into()); + } + let set_ref = app.ui.action_sets.get_mut(set).ok_or("no such action set")?; + for (ai, excluded) in changes { + if let Some(a) = set_ref.actions.get_mut(ai) { + a.excluded_steps = excluded; } - (None, Some(_)) => return Err("no such action step".into()), } Ok(json!({"set": set, "action": action, "step": step, "included": included})) } @@ -673,16 +692,21 @@ pub const RENAME_ACTION: &str = "ui.action.rename"; /// `ui.action.rename {set, action, name}`: renames the action at index `action` of set `set` (0 is /// the top set) to `name` with spaces trimmed from both ends, and keeps its steps, function key and -/// color. An index past the last set or action, or a name with nothing but spaces, is an error, and +/// color. An index past the last set or action, a name with nothing but spaces, or a name that would +/// give the set a file that Load Actions… refuses ([`edit_keeps_set_loadable`]) is an error, and /// then nothing is renamed. → {name} pub fn rename_action(app: &mut VectorcraftApp, p: &Value) -> Result { let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); let name = p.get("name").and_then(Value::as_str).map(str::trim).unwrap_or_default(); - let action = index("set").zip(index("action")).and_then(|(s, a)| app.ui.action_sets.get_mut(s)?.actions.get_mut(a)).ok_or("no such action")?; + let (set, at) = + index("set").zip(index("action")).filter(|&(s, a)| app.ui.action_sets.get(s).is_some_and(|s| a < s.actions.len())).ok_or("no such action")?; if name.is_empty() { return Err("name is empty".into()); } - action.name = name.to_string(); + let mut edited = app.ui.action_sets[set].clone(); + edited.actions[at].name = name.to_string(); + edit_keeps_set_loadable(app, Some(set), &edited)?; + app.ui.action_sets[set] = edited; Ok(json!({ "name": name })) } @@ -696,7 +720,8 @@ pub const NEW_ACTION: &str = "ui.action.new"; /// it, and `color` (one of [`COLORS`]; null or `""` for none) its Button Mode color. The action's /// function key takes priority over command shortcuts. The set becomes current and the only selected set, and the new action is selected. 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 them, nothing is added. The New Action dialog's Record runs this. → {set, action} +/// not one of them, or an action that would give the set a file that Load Actions… refuses +/// ([`edit_keeps_set_loadable`]: 1,000 actions, 8 MiB), nothing is added. The New Action dialog's Record runs this. → {set, action} pub fn new_action(app: &mut VectorcraftApp, p: &Value) -> Result { if app.ui.recording.is_some() { return Err("an action is already recording".into()); @@ -714,9 +739,11 @@ pub fn new_action(app: &mut VectorcraftApp, p: &Value) -> Result } let action = Action { key, color, ..Action::new(name.clone(), vec![]) }; let id = action.id; - let Some(s) = app.ui.action_sets.get_mut(set) else { return Err("no such action set".into()) }; - s.actions.push(action); - let index = s.actions.len() - 1; + let Some(mut edited) = app.ui.action_sets.get(set).cloned() else { return Err("no such action set".into()) }; + edited.actions.push(action); + edit_keeps_set_loadable(app, Some(set), &edited)?; + let index = edited.actions.len() - 1; + app.ui.action_sets[set] = edited; start_recording(app, set, name, Some(id)); app.ui.action_set = Some(set); app.ui.action_sets_selected = vec![set]; @@ -736,7 +763,8 @@ pub const ACTION_OPTIONS: &str = "ui.action.options"; /// [`COLORS`]; null or `""` for none) its Button Mode color; a param left out keeps what the action /// has, and `cmd` or `shift` alone changes that modifier of the action's key. The action's function /// key takes priority over command shortcuts. An index past the last set or action, an empty name, -/// or a key or color that is not one of them is an error, and then nothing changes. The panel +/// a key or color that is not one of them, or a change that would give the set a file that Load +/// Actions… refuses ([`edit_keeps_set_loadable`]) is an error, and then nothing changes. The panel /// menu's Action Options… runs this for the selected action. → {name, key, color} pub fn action_options(app: &mut VectorcraftApp, p: &Value) -> Result { let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); @@ -767,7 +795,8 @@ pub fn action_options(app: &mut VectorcraftApp, p: &Value) -> Result Result Result { if app.ui.recording.is_some() { return Err("an action is already recording".into()); @@ -832,8 +866,6 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { app.ui.action_set.filter(|&i| i < app.ui.action_sets.len()).unwrap_or(0) } }; - app.ui.action_set = Some(set); - app.ui.action_sets_selected = vec![set]; let mut number = app.ui.action_sets[set].actions.len() + 1; let name = loop { let name = format!("Action {number}"); @@ -842,6 +874,12 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { } number += 1; }; + // Stop Playing/Recording adds the action even with no steps. + let mut edited = app.ui.action_sets[set].clone(); + edited.actions.push(Action::new(name.clone(), vec![])); + edit_keeps_set_loadable(app, Some(set), &edited)?; + app.ui.action_set = Some(set); + app.ui.action_sets_selected = vec![set]; start_recording(app, set, name.clone(), None); Ok(json!({ "set": set, "name": name })) } @@ -850,7 +888,8 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { /// Action made for it, else in a new action after the last action of its set, as the bottom bar's /// Stop Playing/Recording does. It also ends a playback at Step by Step or Pause For, or one that /// waits at a stop, whose message box closes: the steps already played stay, and the rest do not -/// run. With nothing recording or playing it changes nothing. → {stopped} +/// run. With nothing recording or playing it changes nothing. Recorded steps past the limits of an +/// action set file are left out ([`stop_recording`]). → {stopped} pub fn stop(app: &mut VectorcraftApp, _: &Value) -> Result { let playing = app.ui.action_playing.take().is_some(); close_stop_message(app); @@ -909,34 +948,215 @@ pub fn insert_menu_item(app: &mut VectorcraftApp, p: &Value) -> Result Result<(), String> { - let Some((set, name, start)) = app.ui.recording.clone() else { return Err("no such action".into()) }; - let start = app.session.journal.held().unwrap_or(start); - let mut steps = app.session.journal_for_action(start); - let into = app.ui.recording_into; - let holder = into.and_then(|id| app.ui.action_sets.iter().position(|s| s.actions.iter().any(|a| a.id == id))).unwrap_or(set); - let Some(mut s) = app.ui.action_sets.get(holder).cloned() else { return Ok(()) }; - let at = match into.and_then(|id| s.actions.iter().position(|a| a.id == id)) { +/// The journal entry the recording in progress records from. +fn recording_start(app: &VectorcraftApp) -> Option { + let (_, _, start) = app.ui.recording.as_ref()?; + // A canceled undo group numbers the steps down when it drops entries made before them. + Some(app.session.journal.held().unwrap_or(*start)) +} + +/// Where Stop Playing/Recording puts the steps of the recording in progress: the index of the set, +/// and of the action New Action made for it, or None for the new action Stop adds after the set's +/// last action. None when there is no recording, no set to put them in, or the action New Action +/// made for it was deleted, so that Stop keeps none of them. +fn recording_target(app: &VectorcraftApp) -> Option<(usize, Option)> { + let (set, ..) = app.ui.recording.as_ref()?; + let Some(id) = app.ui.recording_into else { + app.ui.action_sets.get(*set)?; + return Some((*set, None)); + }; + app.ui.action_sets.iter().enumerate().find_map(|(holder, s)| Some((holder, Some(s.actions.iter().position(|a| a.id == id)?)))) +} + +/// The set that Stop Playing/Recording leaves when the recording in progress has recorded `steps`, +/// and the index of the action they go into ([`recording_target`]). +fn set_with_recorded(app: &VectorcraftApp, steps: Vec<(String, Value)>) -> Option<(ActionSet, usize)> { + let (holder, at) = recording_target(app)?; + let mut s = app.ui.action_sets.get(holder)?.clone(); + let at = match at { Some(at) => at, None => { - s.actions.push(Action::new(name, vec![])); + s.actions.push(Action::new(app.ui.recording.as_ref()?.1.clone(), vec![])); s.actions.len() - 1 } }; - if s.actions[at].steps.len() + steps.len() >= files::MAX_ACTION_STEPS { + extend_recorded(s.actions.get_mut(at)?, steps); + Some((s, at)) +} + +/// The length of the action set file of set `set`, with the steps that the recording in progress +/// adds to it ([`files::write_set`]). +fn set_file_len(app: &VectorcraftApp, set: usize) -> usize { + if recording_target(app).is_some_and(|(holder, _)| holder == set) + && let Some((s, _)) = recording_start(app).and_then(|start| set_with_recorded(app, app.session.journal_for_action(start))) + { + return files::write_set(&s).len(); + } + app.ui.action_sets.get(set).map_or(0, |s| files::write_set(s).len()) +} + +/// The most that Load Actions… reads from an action set file ([`files::read_set`]), each with the +/// error an edit past it gives: the steps of one action, the actions of a set and the bytes of the +/// file. +const SET_LIMITS: [(usize, &str); 3] = [ + (files::MAX_ACTION_STEPS, "the action already has 10,000 steps"), + (files::MAX_SET_ACTIONS, "the action set file holds more than 1,000 actions"), + (files::MAX_SET_FILE, "the action set would be larger than 8 MiB"), +]; + +/// The sizes of `set` that [`SET_LIMITS`] bound, as the set at index `at` (None for a new set): +/// the most steps one action has, the actions and the length of its file. When the recording in +/// progress records into the set at `at` ([`recording_target`]), its steps count too. +fn set_sizes(app: &VectorcraftApp, at: Option, set: &ActionSet) -> [usize; 3] { + let recorded = recording_target(app) + .filter(|&(holder, _)| Some(holder) == at) + .and_then(|_| Some((recording_start(app)?, app.ui.recording.as_ref()?.1.clone()))); + let with; + let set = match recorded { + Some((start, name)) => { + let mut s = set.clone(); + let steps = app.session.journal_for_action(start); + match app.ui.recording_into.and_then(|id| s.actions.iter().position(|a| a.id == id)) { + Some(i) => extend_recorded(&mut s.actions[i], steps), + None => s.actions.push(Action::new(name, steps)), + } + with = s; + &with + } + None => set, + }; + [set.actions.iter().map(|a| a.steps.len()).max().unwrap_or(0), set.actions.len(), files::write_set(set).len()] +} + +/// Err when `edited`, in place of the set at index `at` (None: added as a new set), would give an +/// action set file that Load Actions… refuses ([`SET_LIMITS`]), counting the steps that the +/// recording in progress adds to it. An edit that leaves the set no further past a limit than it +/// is now is not refused. +pub(crate) fn edit_keeps_set_loadable(app: &VectorcraftApp, at: Option, edited: &ActionSet) -> Result<(), String> { + let after = set_sizes(app, at, edited); + if after.iter().zip(SET_LIMITS).all(|(&size, (limit, _))| size <= limit) { + return Ok(()); + } + let before = at.and_then(|i| app.ui.action_sets.get(i)).map_or([0; 3], |s| set_sizes(app, at, s)); + for ((size, was), (limit, error)) in after.into_iter().zip(before).zip(SET_LIMITS) { + if size > limit && size > was { + return Err(error.into()); + } + } + Ok(()) +} + +/// The number of steps the action that the recording in progress records into has of its own: 0 +/// for the new action Stop Playing/Recording adds. +fn recording_target_steps(app: &VectorcraftApp) -> usize { + recording_target(app).and_then(|(holder, at)| app.ui.action_sets.get(holder)?.actions.get(at?)).map_or(0, |a| a.steps.len()) +} + +/// The bytes that the journal entries from `start` on add to an action set file after a step +/// ([`files::step_bytes`]), at least as many as the steps recorded from them add. Only the entries +/// journaled since the last count are counted, unless the journal rewrote entries since +/// ([`crate::UiState::recording_tail`]). +fn recorded_bytes(app: &mut VectorcraftApp, start: usize) -> usize { + let journal = &app.session.journal; + let (rewrites, len) = (journal.rewrites(), journal.len()); + let (from, counted) = match app.ui.recording_tail { + Some((r, s, counted_len, bytes)) if r == rewrites && s == start && counted_len <= len => (counted_len, bytes), + _ => (start, 0), + }; + let bytes = counted + journal.since(from).iter().map(|(c, p)| files::step_bytes(c, p)).sum::(); + app.ui.recording_tail = Some((rewrites, start, len, bytes)); + bytes +} + +/// Err when adding the step `(command, params)` to the recording in progress would give the action +/// that Stop Playing/Recording keeps more than [`files::MAX_ACTION_STEPS`] steps, or its set a file +/// larger than [`files::MAX_SET_FILE`], the most Load Actions… reads. The file without the steps +/// recorded so far is measured once ([`recording_base`]), and the recorded steps add +/// [`recorded_bytes`] to it. Only when that sum is past the limit is the whole file written out to +/// measure it. +fn recording_room(app: &mut VectorcraftApp, command: &str, params: &Value) -> Result<(), String> { + let Some(start) = recording_start(app) else { return Err("no such action".into()) }; + if recording_target(app).is_none() { + return Ok(()); + } + let stored = recording_target_steps(app); + if stored + app.session.journal.since(start).len() >= files::MAX_ACTION_STEPS { return Err("the action already has 10,000 steps".into()); } - steps.push(step.clone()); - s.actions[at].steps.extend(steps); - if files::write_set(&s).len() > files::MAX_SET_FILE { + let first = if stored == 0 { files::FIRST_STEP_BYTES } else { 0 }; + let added = recorded_bytes(app, start) + files::step_bytes(command, params) + first; + if recording_base(app) + added <= files::MAX_SET_FILE { + return Ok(()); + } + let mut steps = app.session.journal_for_action(start); + steps.push((command.to_string(), params.clone())); + if set_with_recorded(app, steps).is_some_and(|(s, _)| files::write_set(&s).len() > files::MAX_SET_FILE) { return Err("the action set would be larger than 8 MiB".into()); } Ok(()) } +/// The length of the action set file that Stop Playing/Recording would leave without the steps +/// recorded so far ([`crate::UiState::recording_base`]). +fn recording_base(app: &mut VectorcraftApp) -> usize { + if let Some(base) = app.ui.recording_base { + return base; + } + let base = set_with_recorded(app, vec![]).map_or(0, |(s, _)| files::write_set(&s).len()); + app.ui.recording_base = Some(base); + base +} + +/// Err when the steps recorded so far no longer fit in the action that Stop Playing/Recording gives +/// them ([`steps_that_fit`]): a command added values to its step after [`recording_room`] measured +/// it. The steps are written out to measure them only when [`recorded_bytes`] puts them past the +/// limit. +fn recorded_steps_fit(app: &mut VectorcraftApp) -> Result<(), String> { + let Some(start) = recording_start(app) else { return Ok(()) }; + let stored = recording_target_steps(app); + let recorded = app.session.journal.since(start).len(); + let first = if stored == 0 && recorded > 0 { files::FIRST_STEP_BYTES } else { 0 }; + if stored + recorded <= files::MAX_ACTION_STEPS && recording_base(app) + recorded_bytes(app, start) + first <= files::MAX_SET_FILE { + return Ok(()); + } + let steps = app.session.journal_for_action(start); + match steps_that_fit(app, &steps) { + (_, Some(limit)) => Err(limit.to_string()), + (_, None) => Ok(()), + } +} + +/// Before the engine command `id` runs with `params`: Err when the recording in progress would keep +/// it as a step that does not fit in its action ([`recording_room`]); else whether the recording +/// would keep it, for [`after_recorded_command`]. A command is kept as a step when it is journaled, +/// a batch (`command.batch`, which journals itself) too, and Find Objects when it selects what it +/// finds. Undo and Redo are steps too, and are refused when they do not fit. +pub(crate) fn before_recorded_command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result { + let journaled = vectorcraft_engine::find_command(id).is_some_and(|c| c.journal) + || id == "command.batch" + || (id == "document.find" && params.get("select").and_then(Value::as_bool) == Some(true)); + if app.ui.recording.is_none() || !journaled || app.session.active().is_some_and(|d| d.interaction.is_some()) { + return Ok(false); + } + recording_room(app, id, params)?; + Ok(true) +} + +/// After a command that the recording keeps as a step (`recorded`, [`before_recorded_command`]) has +/// run with result `r`: when the values the command added to its step as it ran make the steps no +/// longer fit ([`recorded_steps_fit`]), the command's edit and its result stay, the status bar +/// names the limit, no later recorded command runs, and Stop Playing/Recording leaves the step out +/// ([`steps_that_fit`]) → the command's result. +pub(crate) fn after_recorded_command(app: &mut VectorcraftApp, recorded: bool, r: Result) -> Result { + if recorded + && r.is_ok() + && let Err(e) = recorded_steps_fit(app) + { + app.status(e); + } + r +} + /// What Stop Playing/Recording adds to a set ([`recorded_into`]). struct Recorded { /// The set's action that the recording records into; None for the new action that the @@ -970,7 +1190,7 @@ fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result< if app.ui.recording.is_none() { return Err("no such action".into()); } - recording_room(app, &step)?; + recording_room(app, &step.0, &step.1)?; app.session.journal.push(step); return Ok(json!({"recording": true})); } @@ -1104,20 +1324,59 @@ fn start_recording(app: &mut VectorcraftApp, set: usize, name: String, into: Opt app.session.journal.hold(Some(start)); app.ui.recording = Some((set, name, start)); app.ui.recording_into = into; + app.ui.recording_base = None; + app.ui.recording_tail = None; +} + +/// How many of `steps`, from the first, Stop Playing/Recording can give the recording's action so +/// that its set stays one that Load Actions… reads ([`recording_room`]), and the error that names +/// the limit when that is fewer than all of them. +fn steps_that_fit(app: &VectorcraftApp, steps: &[(String, Value)]) -> (usize, Option<&'static str>) { + let Some((s, _)) = set_with_recorded(app, vec![]) else { return (steps.len(), None) }; + let stored = recording_target_steps(app); + let keep = steps.len().min(files::MAX_ACTION_STEPS.saturating_sub(stored)); + // Each step adds `step_bytes` to the file, and the action's first step a little more. + let mut size = files::write_set(&s).len() + if stored == 0 { files::FIRST_STEP_BYTES } else { 0 }; + let fitting = steps[..keep] + .iter() + .take_while(|(c, p)| { + size += files::step_bytes(c, p); + size <= files::MAX_SET_FILE + }) + .count(); + if fitting < keep { + return (fitting, Some("the action set would be larger than 8 MiB")); + } + (keep, (keep < steps.len()).then_some("the action already has 10,000 steps")) +} + +/// Adds the recorded `steps` after the steps of `action`, included in playback. An exclusion past +/// the action's last step, which saved preferences can hold, goes. +fn extend_recorded(action: &mut Action, steps: Vec<(String, Value)>) { + let len = action.steps.len(); + action.excluded_steps.retain(|&i| i < len); + action.steps.extend(steps); } /// Stop Playing/Recording: ends the recording and keeps its steps (see [`start_recording`]). An -/// action New Action made that was deleted while it recorded keeps nothing. +/// action New Action made that was deleted while it recorded keeps nothing. Steps past the limits +/// of an action set file ([`steps_that_fit`]) are left out, and the status bar names the limit. fn stop_recording(app: &mut VectorcraftApp) { - let Some((set, name, start)) = app.ui.recording.take() else { return }; - // A canceled undo group numbers the steps down when it drops entries made before them. - let start = app.session.journal.held().unwrap_or(start); - let steps = app.session.journal_for_action(start); + let Some(start) = recording_start(app) else { return }; + let mut steps = app.session.journal_for_action(start); + let (keep, limit) = steps_that_fit(app, &steps); + steps.truncate(keep); + if let Some(limit) = limit { + app.status(limit); + } + let Some((set, name, _)) = app.ui.recording.take() else { return }; + app.ui.recording_base = None; + app.ui.recording_tail = None; app.session.journal.hold(None); match app.ui.recording_into.take() { Some(id) => { if let Some(a) = app.ui.action_sets.iter_mut().flat_map(|s| s.actions.iter_mut()).find(|a| a.id == id) { - a.steps.extend(steps); + extend_recorded(a, steps); } } None => { @@ -1342,7 +1601,9 @@ fn duplicate_selection(app: &mut VectorcraftApp, ctx: &egui::Context) { /// named after the original with " copy" added, and a copied set gets an id of its own /// ([`ActionSet::id`]). Copied actions have no function key, so a key plays only the original. /// The copy is selected and its set is the current one. A recording and an open Set Options or -/// Batch dialog keep to their own set and action. → {set, action?} +/// Batch dialog keep to their own set and action. A copy that would give its set a file that Load +/// Actions… refuses ([`edit_keeps_set_loadable`]) is an error, and nothing is copied. +/// → {set, action?} pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { const SELECTED: &str = "actions-selected"; let len = app.ui.action_sets.len(); @@ -1351,7 +1612,7 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { None | Some(Value::Null) => app.ui.action_set.filter(|&s| s < len).ok_or("no action set is current")?, Some(v) => index(v).filter(|&s| s < len).ok_or("no such action set")?, }; - let Some(src) = app.ui.action_sets.get_mut(set) else { return Err("no such action set".into()) }; + let Some(src) = app.ui.action_sets.get(set) else { return Err("no such action set".into()) }; let action = match p.get("action") { None | Some(Value::Null) => None, Some(v) => Some(index(v).filter(|&a| a < src.actions.len()).ok_or("no such action")?), @@ -1359,7 +1620,10 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { let (copy, result) = if let Some(a) = action { let Some(mut copy) = src.actions.get(a).map(Action::copy) else { return Err("no such action".into()) }; copy.name = format!("{} copy", copy.name); - src.actions.insert(a + 1, copy); + let mut edited = src.clone(); + edited.actions.insert(a + 1, copy); + edit_keeps_set_loadable(app, Some(set), &edited)?; + app.ui.action_sets[set] = edited; // A Batch, Record Stop or Insert Menu Item dialog open on a later action of the set follows it // down. if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND || holds_step(d)) @@ -1371,6 +1635,7 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { (Some((set, a + 1)), json!({ "set": set, "action": a + 1 })) } else { let copy = ActionSet::new(format!("{} copy", src.name), src.actions.iter().map(Action::copy).collect()); + edit_keeps_set_loadable(app, None, ©)?; app.ui.action_sets.insert(set + 1, copy); if let Some((rec, ..)) = app.ui.recording.as_mut() && *rec > set @@ -4258,12 +4523,12 @@ mod tests { assert_eq!(files::read_set(&saved, "Test").unwrap().actions.last().unwrap().steps.len(), files::MAX_ACTION_STEPS, "and loads again"); let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({})).unwrap(); 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 - 8)]; 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"; assert_eq!(app.run(INSERT_MENU_ITEM, json!({"command": "object.group"})).unwrap_err(), size); @@ -4320,6 +4585,481 @@ mod tests { } } + /// `files::step_bytes` is what a step adds to the file after another step, and + /// `files::FIRST_STEP_BYTES` less than it adds as an action's first step, with quotes, + /// backslashes, control characters, text outside ASCII, nested params, a stop that changes the + /// file's version and empty params. Excluding a step adds `files::EXCLUDED_BYTES`. + #[test] + fn step_bytes_count_what_a_step_adds_to_an_action_set_file() { + let steps = [ + ("object.move", json!({"dx": 1, "dy": 0})), + ("type.set", json!({"text": "say \"hi\" \\ back\tslash\nline \u{1} é 日本 🎨 "})), + ( + "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/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 9192aa5f0b..7c0a18d14f 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -1,8 +1,14 @@ //! Glyphs panel: every character the current font maps (its cmap), drawn from the font's own -//! outlines. Double-click inserts the character at the Type tool's caret (or appends it to the -//! selected text objects). The character selected with the Type tool is highlighted in the grid. -//! Show › Alternates for Current Selection lists that character's alternate glyphs instead, and a -//! double-click draws the character with one of them. +//! outlines; Show › Entire Font adds the glyphs no character maps, by glyph id. Double-click +//! inserts the character at the Type tool's caret (or appends it to the selected text objects). +//! The character selected with the Type tool is highlighted in the grid. +//! Show › Alternates for Current Selection lists that character's alternate glyphs instead (as it +//! stands in its word), and a double-click applies the OpenType feature that draws one to that +//! character alone, so the text stays real text that joins its neighbors. A double-click on a +//! glyph no character maps in Entire Font does the same when the selected character's font gives +//! that glyph as one of its alternates in its word. A glyph no feature reaches whose name does +//! not belong to another letter is applied as a glyph choice, which may not join. Other glyphs +//! are drawn dimmed, and their tooltips say why they cannot be applied to the selection. use std::collections::HashMap; @@ -192,23 +198,26 @@ fn selected_target(app: &VectorcraftApp) -> Option> { None } -/// Insert `ch` at the Type tool's caret, else append it to the selected text. -fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) { +/// Insert `ch` at the Type tool's caret, else append it to the selected text; whether it succeeded. +fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) -> bool { let s = ch.to_string(); if app.session.tool_wants_text() { let v = app.view_info(); let target_before = crate::ime::target(&mut app.session, v); let before = crate::ime::stamp(&mut app.session, v); let r = app.session.tool_text(&s, v); + let inserted = r.is_ok(); match &r { Err(e) => app.status(e.to_string()), Ok(_) => crate::ime::note_application_edit(app, ctx, v, target_before, before), } app.sync_views(); + inserted } else if super::character::text_style(app).is_some() { - app.run("type.insert", json!({"text": s})).ok(); + app.run("type.insert", json!({"text": s})).is_ok() } else { app.ui.status = "Select text or click into text with the Type tool to insert glyphs".into(); + false } } @@ -235,19 +244,31 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { font_row(app, ui, &family, &style, w); return; } - let Some(face) = db.face(&family, &style) else { + // The same family and style can have a different face id at the selection's version or axes. + // Use that instance for both the previews and Fit's applicability check. + let face = selected_target(app) + .filter(|t| t.style.font_family.eq_ignore_ascii_case(&family) && t.style.font_style.eq_ignore_ascii_case(&style)) + .and_then(|t| db.style_face(t.style)) + .or_else(|| db.face(&family, &style)); + let Some(face) = face else { ui.label(egui::RichText::new(tl!("No fonts are available.")).color(t.text_dim)); return; }; let chars: Vec<(char, u32)> = face.chars().into_iter().filter(|(c, _)| (lo..=hi).contains(&(*c as u32)) && !c.is_control() && !c.is_whitespace()).collect(); + // Entire Font also lists the glyphs no character maps (positional forms, alternates, + // ligatures) by glyph id, after the characters. + let unencoded = if subset == 0 { face.unencoded_glyphs() } else { Default::default() }; + let total = chars.len() + unencoded.len(); let cell = CELL_STEPS[cell_index(app)]; let cols = ((w / cell).floor() as usize).max(1); - let rows = chars.len().div_ceil(cols); + let rows = total.div_ceil(cols); let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; let list_h = widgets::panel_list_height(ui, (ui.available_height() - 64.0).max(cell * 3.0), 33.0); let hover_id = egui::Id::unique("gl-hover"); let selected = selected_char(app); + // What a double-click on an unencoded glyph can do to the selected character. + let fit = (!unencoded.is_empty()).then(|| Fit::of(app, ui, &face)); // The selected character and the rows shown last frame: the grid scrolls to the selected glyph // only when the selection moves to another character, so it still scrolls freely meanwhile. let follow_id = egui::Id::unique("gl-follow"); @@ -259,46 +280,91 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { && !shown.contains(&(i / cols)) { let row_h = cell + ui.spacing().item_spacing.y; - area = area.vertical_scroll_offset(((i / cols) as f32 + 0.5) * row_h - list_h * 0.5); + area = area.vertical_scroll_offset((((i / cols) as f32 + 0.5) * row_h - list_h * 0.5).max(0.0)); } - let shown = area.show_rows(ui, cell, rows, |ui, range| { - for r in range.clone() { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = vec2(0.0, 0.0); - for &(c, gid) in chars.iter().skip(r * cols).take(cols) { - let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); - let chosen = selected == Some(c); - if resp.hovered() { - ui.painter().rect_filled(rect, 2, t.hover); - ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid))); - } else if chosen { - ui.painter().rect_filled(rect, 2, t.row_selected); + let shown = + area.show_rows(ui, cell, rows, |ui, range| { + for r in range.clone() { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + for k in r * cols..((r + 1) * cols).min(total) { + let (c, gid) = match chars.get(k) { + Some(&(c, gid)) => (Some(c), gid), + None => (None, unencoded.get(k - chars.len()).copied().unwrap_or_default()), + }; + let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); + let chosen = c.is_some() && selected == c; + if resp.hovered() { + ui.painter().rect_filled(rect, 2, t.hover); + ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid))); + } else if chosen { + ui.painter().rect_filled(rect, 2, t.row_selected); + } + let edge = if chosen { Stroke::new(1.5, t.accent) } else { Stroke::new(0.5, t.border) }; + ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside); + // A glyph no character maps is dimmed when it cannot go on the selection. + let pick = match (c, &fit) { + (None, Some(fit)) => Some(fit.pick(&face, gid)), + _ => None, + }; + let ink = if matches!(pick, Some(None)) { t.text_disabled } else { t.text_strong }; + let tex = glyph_texture(ui.ctx(), &face, gid, px); + ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), ink); + // The tooltip is made when it is shown: working out why a glyph cannot be + // applied reads the font, which hundreds of cells cannot do each frame. + let resp = resp.on_hover_ui(|ui| { + let tip = match (c, &pick) { + (Some(c), _) => encoded_tip(&face, c, gid), + (None, Some(Some(Pick::Glyph(_)))) => format!( + "{}\n{}\n{}", + unencoded_tip(&face, gid), + tl!("Double-click to replace"), + tl!("No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors.") + ), + (None, Some(Some(_))) => format!("{}\n{}", unencoded_tip(&face, gid), tl!("Double-click to replace")), + (None, Some(None)) => { + let why = fit.as_ref().map(|f| f.why(&face, gid)).unwrap_or_default(); + format!("{}\n{why}", unencoded_tip(&face, gid)) + } + (None, None) => unencoded_tip(&face, gid), + }; + ui.label(tip); + }); + if resp.double_clicked() { + match (c, pick) { + (Some(c), _) => insert_character(app, ui.ctx(), &face, c, gid), + (None, Some(Some(pick))) => { + if let Some(fit) = &fit { + fit.apply(app, pick); + } + } + (None, Some(None)) => { + if let Some(fit) = &fit { + app.status(fit.why(&face, gid)); + } + } + (None, None) => {} + } + } } - let edge = if chosen { Stroke::new(1.5, t.accent) } else { Stroke::new(0.5, t.border) }; - ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside); - let tex = glyph_texture(ui.ctx(), &face, gid, px); - ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); - let resp = resp.on_hover_text(format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert"))); - if resp.double_clicked() { - insert(app, ui.ctx(), c); - } - } - }); - } - range - }); + }); + } + range + }); ui.ctx().data_mut(|d| d.insert_temp(follow_id, (selected, shown.inner))); ui.add_space(4.0); - let info = ui.ctx().data(|d| d.get_temp::<(char, u32)>(hover_id)); + let info = ui.ctx().data(|d| d.get_temp::<(Option, u32)>(hover_id)); + let count = crate::i18n::tn(total as u64, "{n} glyph", "{n} glyphs"); ui.label( egui::RichText::new(match info { - Some((c, g)) => format!( - "U+{:04X} {} · {}", - c as u32, - crate::i18n::fmt(tl!("glyph {id}"), &[("id", &g.to_string())]), - crate::i18n::tn(chars.len() as u64, "{n} glyph", "{n} glyphs") - ), - None => crate::i18n::tn(chars.len() as u64, "{n} glyph", "{n} glyphs"), + Some((c, g)) => { + let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &g.to_string())]); + match c { + Some(c) => format!("U+{:04X} {glyph} · {count}", c as u32), + None => format!("{glyph} · {count}"), + } + } + None => count, }) .size(11.0) .color(t.text_dim), @@ -342,11 +408,36 @@ fn font_row(app: &mut VectorcraftApp, ui: &mut Ui, family: &str, style: &str, w: }); } +/// One cell of Show › Alternates for Current Selection: a glyph, the feature that lists it, and the +/// feature tag that draws it on the selected character in its word (`salt=2`), when one does. +struct Variant { + gid: u32, + feature: [u8; 4], + apply: Option, + /// The glyph the shaper makes for the character with none of its own alternate features. + default: bool, +} + +/// What a double-click on a variant does to the selected character. +enum Pick { + /// Back to the default glyph: its alternate features off, a glyph choice taken away. + Default, + /// A feature that draws the variant: the text stays real Unicode and its word stays joined. + Feature(String), + /// No feature draws it: it replaces the character's glyph (`text.setGlyphAlternate`), which is + /// shaped apart from its neighbors. + Glyph(u32), +} + /// The grid of Show › Alternates for Current Selection: the alternate glyphs of the Type tool's -/// selected character in the face its text is set in, whatever font the panel browses -/// ([`vectorcraft_text::FontFace::alternates`]), with that face's name under the grid. The default -/// glyph comes first when the font does not list it. The glyph the character is drawn with is -/// highlighted. A double-click draws the character with the glyph (`text.setGlyphAlternate`). +/// selected character as it stands in its word ([`vectorcraft_text::alternates_in_context`]: a +/// medial letter lists its medial form's alternates), in the face the text is set in, whatever font +/// the panel browses, with that face's name under the grid. The glyph the shaper makes for the +/// character with none of its own alternate features comes first, and the glyph the character is +/// drawn with is highlighted. A double-click applies the OpenType feature that draws the glyph to +/// that character alone (`text.setRangeStyle`, `salt=2`), as one undo step, so the text stays real +/// text that joins its neighbors. A glyph no feature draws there replaces the character's glyph +/// instead (`text.setGlyphAlternate`), and its tooltip says that it may not join. fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { let t = Tokens::get(ui.ctx()); let db = FontDb::global(); @@ -362,13 +453,25 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { return note(ui, tl!("The selected character's font is not available.")); }; let (id, byte, ch) = (target.id, target.byte, target.ch); - let (list, drawn) = alternates_and_drawn(app, ui, &face, id, byte, ch); + let (features, has_choice) = (target.style.features.clone(), target.style.glyph_alternate.is_some()); + let found = alternates_found(app, ui, id, byte, face.id()); let font = format!("{} {}", face.family, face.style); - if list.is_empty() { + let Some(found) = found.filter(|f| has_choice || !f.alternates.is_empty()) else { note(ui, tl!("No alternates for the selected character.")); ui.label(egui::RichText::new(font).size(11.0).color(t.text_dim)); return; - } + }; + // The glyphs are drawn from the face that supplied the character's glyph (a fallback face for + // a character the text's font lacks). + let face = db.face_by_id(found.font_id).unwrap_or(face); + let drawn = Some(found.current); + let mut list = vec![Variant { gid: found.default, feature: [0; 4], apply: None, default: true }]; + list.extend(found.alternates.iter().filter(|a| a.gid != found.default).map(|a| Variant { + gid: a.gid, + feature: a.feature, + apply: a.apply.clone(), + default: false, + })); let cell = CELL_STEPS[cell_index(app)]; let cols = ((w / cell).floor() as usize).max(1); let rows = list.len().div_ceil(cols); @@ -387,7 +490,7 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); for a in list.iter().skip(r * cols).take(cols) { let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); - let current = drawn.is_some_and(|(font_id, gid)| font_id == face.id() && a.gid == gid); + let current = drawn == Some(a.gid); if resp.hovered() { ui.painter().rect_filled(rect, 2, t.hover); } else if current { @@ -398,10 +501,25 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { let tex = glyph_texture(ui.ctx(), &face, a.gid, px); ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &a.gid.to_string())]); - let glyph = if a.feature == [0; 4] { glyph } else { format!("{glyph} ({})", String::from_utf8_lossy(&a.feature)) }; - let resp = resp.on_hover_text(format!("{glyph}\n{}", tl!("Double-click to replace"))); + let glyph = match (&a.apply, a.default) { + (_, true) => glyph, + (Some(tag), _) => format!("{glyph} ({tag})"), + (None, _) => format!("{glyph} ({})", String::from_utf8_lossy(&a.feature)), + }; + let mut tip = format!("{glyph}\n{}", tl!("Double-click to replace")); + if !a.default && a.apply.is_none() { + tip.push('\n'); + tip.push_str(tl!( + "No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors." + )); + } + let resp = resp.on_hover_text(tip); if resp.double_clicked() { - pick = Some(a.gid); + pick = Some(match (&a.apply, a.default) { + (_, true) => Pick::Default, + (Some(tag), _) => Pick::Feature(tag.clone()), + (None, _) => Pick::Glyph(a.gid), + }); } } }); @@ -410,28 +528,227 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { ui.add_space(4.0); let count = crate::i18n::tn(list.len() as u64, "{n} glyph", "{n} glyphs"); ui.label(egui::RichText::new(format!("{font} · {count}")).size(11.0).color(t.text_dim)); - if let Some(gid) = pick { + if let Some(pick) = pick { super::character::end_typing(app); - if let Err(e) = app.run("text.setGlyphAlternate", json!({"id": id.0, "start": byte, "gid": gid})) { - app.status(e.to_string()); + apply_alternate(app, id, (byte, byte + ch.len_utf8()), &features, has_choice, pick); + } +} + +/// Do what a double-click on a variant says to the character at `bytes` of text `id`, whose style +/// has `features` and, when `has_choice`, a glyph choice: one command, so one undo step. A feature +/// goes through `text.setRangeStyle` on exactly the character (its other alternate features are +/// turned off, a glyph choice is taken away); a glyph no feature draws through +/// `text.setGlyphAlternate`. The default glyph, or a variant the character already has, changes +/// nothing. +fn apply_alternate(app: &mut VectorcraftApp, id: NodeId, bytes: (usize, usize), features: &[String], has_choice: bool, pick: Pick) { + let (cmd, params) = match pick { + Pick::Glyph(gid) => ("text.setGlyphAlternate", json!({"id": id.0, "start": bytes.0, "gid": gid})), + Pick::Default | Pick::Feature(_) => { + let tag = if let Pick::Feature(tag) = &pick { Some(tag.as_str()) } else { None }; + let changes = vectorcraft_text::alternate_changes(features, tag); + if changes.is_empty() && !has_choice { + return; + } + let mut params = json!({"id": id.0, "start": bytes.0, "end": bytes.1}); + if !changes.is_empty() { + params["featureChanges"] = json!(changes); + } + if has_choice { + params["glyphAlternate"] = serde_json::Value::Null; + } + ("text.setRangeStyle", params) + } + }; + if let Err(e) = app.run(cmd, params) { + app.status(e.to_string()); + } +} + +/// The Type tool's selected character, and what its word offers, for a double-click on a glyph no +/// character maps in Show › Entire Font ([`Fit::pick`]). +struct Fit { + /// The selected character: its text, where it is and how it is styled. `None` without one. + target: Option, + /// The id of the face the panel browses; its glyph ids are what is applied. + face_id: u32, +} + +struct FitTarget { + id: NodeId, + byte: usize, + ch: char, + features: Vec, + has_choice: bool, + /// The family the selected text is set in, and whether the app has it. + family: String, + available: bool, + /// The alternates of the character in its word, from the face of its text. + found: Option, +} + +impl Fit { + fn of(app: &VectorcraftApp, ui: &Ui, face: &vectorcraft_text::FontFace) -> Self { + let db = FontDb::global(); + let target = selected_target(app).map(|t| { + let text_face = db.style_face(t.style).filter(|_| db.has_family(&t.style.font_family)); + let found = text_face.as_ref().and_then(|f| alternates_found(app, ui, t.id, t.byte, f.id())); + FitTarget { + id: t.id, + byte: t.byte, + ch: t.ch, + features: t.style.features.clone(), + has_choice: t.style.glyph_alternate.is_some(), + family: t.style.font_family.clone(), + available: text_face.is_some(), + found, + } + }); + Fit { target, face_id: face.id() } + } + + /// What a double-click on glyph `gid` does to the selected character: the way to draw it with + /// that glyph, when it is the character's default glyph or one of its alternates as it stands + /// in its word, in the face the selected text is set in; or a glyph no feature reaches that + /// the font's naming does not give to another letter (`A.salt` for `A`, `s.ss1` in a font + /// with no `ss01`), which is drawn as a glyph choice. `None` when it is not. + fn pick(&self, face: &vectorcraft_text::FontFace, gid: u32) -> Option { + let t = self.target.as_ref().filter(|t| t.available)?; + let found = t.found.as_ref().filter(|f| f.font_id == self.face_id)?; + if gid == found.default { + return Some(Pick::Default); + } + if let Some(a) = found.alternates.iter().find(|a| a.gid == gid) { + return Some(a.apply.clone().map_or(Pick::Glyph(gid), Pick::Feature)); + } + face.is_unlisted_alternate(t.ch, gid).then_some(Pick::Glyph(gid)) + } + + /// Why [`Self::pick`] finds nothing for glyph `gid` of `face`, in the user's words: what the + /// glyph belongs to (its letter and place in a word, from the font's own substitutions) and what + /// the selection is, or that nothing is selected. + fn why(&self, face: &vectorcraft_text::FontFace, gid: u32) -> String { + let Some(t) = &self.target else { return tl!("Select one character with the Type tool first.").to_string() }; + if !t.available { + return tl!("The selected character's font is not available.").to_string(); + } + let name = glyph_label(face, gid); + if let Some(found) = t.found.as_ref().filter(|f| f.font_id != self.face_id) { + let other = FontDb::global().face_by_id(found.font_id).map_or_else(|| t.family.clone(), |f| format!("{} {}", f.family, f.style)); + return crate::i18n::fmt( + tl!("{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font})."), + &[("glyph", &name), ("font", &format!("{} {}", face.family, face.style)), ("text_font", &other)], + ); + } + let (sel, sel_code) = (t.ch.to_string(), format!("{:04X}", t.ch as u32)); + let not_alternate = || { + crate::i18n::fmt( + tl!("{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word."), + &[("glyph", &name), ("sel", &sel), ("code", &sel_code)], + ) + }; + let Some(origin) = face.glyph_origin(gid) else { return not_alternate() }; + let place = match origin.position() { + Some(vectorcraft_text::JoiningPosition::Initial) => Some(tl!("at the start of a word")), + Some(vectorcraft_text::JoiningPosition::Medial) => Some(tl!("in the middle of a word")), + Some(vectorcraft_text::JoiningPosition::Final) => Some(tl!("at the end of a word")), + Some(vectorcraft_text::JoiningPosition::Isolated) => Some(tl!("standing alone")), + None => None, + }; + let (Some(place), true) = (place, origin.ch != t.ch) else { return not_alternate() }; + crate::i18n::fmt( + tl!("{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code})."), + &[ + ("glyph", &name), + ("base", &origin.ch.to_string()), + ("base_code", &format!("{:04X}", origin.ch as u32)), + ("place", place), + ("sel", &sel), + ("code", &sel_code), + ], + ) + } + + /// Apply `pick` to the selected character, as a double-click on a variant of Show › Alternates + /// for Current Selection does. + fn apply(&self, app: &mut VectorcraftApp, pick: Pick) { + let Some(t) = &self.target else { return }; + super::character::end_typing(app); + apply_alternate(app, t.id, (t.byte, t.byte + t.ch.len_utf8()), &t.features, t.has_choice, pick); + } +} + +/// `name` of glyph `gid`, or `gid N`. +fn glyph_label(face: &vectorcraft_text::FontFace, gid: u32) -> String { + face.glyph_name(gid).unwrap_or_else(|| format!("gid {gid}")) +} + +/// The letter to insert in place of character `c`, an Arabic presentation form: the letter whose +/// glyph the font substitutes with `init`, `medi`, `fina` or `isol` to get `c`'s glyph. The +/// presentation form itself is a fixed shape that neither joins its neighbors nor is joined by +/// them; the letter takes the form its place in the word asks for, so the word stays joined. +/// `None` when `c` is not such a form in this font. +fn base_letter(face: &vectorcraft_text::FontFace, c: char, gid: u32) -> Option { + if !matches!(c, '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}') { + return None; + } + let origin = face.glyph_origin(gid)?; + origin.position()?; + (origin.ch != c).then_some(origin.ch) +} + +/// The tooltip of an encoded glyph (a character): its code point and glyph, and what a +/// double-click inserts. +fn encoded_tip(face: &vectorcraft_text::FontFace, c: char, gid: u32) -> String { + let head = format!("U+{:04X} {c} (glyph {gid})", c as u32); + match base_letter(face, c, gid) { + Some(base) => format!( + "{head}\n{}", + crate::i18n::fmt( + tl!("Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does."), + &[("base", &base.to_string()), ("code", &format!("{:04X}", base as u32))] + ) + ), + None => format!("{head}\n{}", tl!("Double-click to insert")), + } +} + +/// Double-click on character `c` of the grid: insert it, or its letter when it is an Arabic +/// presentation form ([`base_letter`]), which the status says. +fn insert_character(app: &mut VectorcraftApp, ctx: &egui::Context, face: &vectorcraft_text::FontFace, c: char, gid: u32) { + match base_letter(face, c, gid) { + Some(base) => { + if insert(app, ctx, base) { + app.status(crate::i18n::fmt( + tl!("Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position."), + &[("base", &base.to_string()), ("code", &format!("{:04X}", base as u32))], + )); + } + } + None => { + insert(app, ctx, c); } } } -/// The alternates of `ch` in `face` and the face id and glyph the text object `id` draws the -/// character at `byte` with. Both are kept in egui's temporary data until the document, the -/// character or the face changes, as [`super::character`] keeps the kerning at the caret. -fn alternates_and_drawn( - app: &VectorcraftApp, - ui: &Ui, - face: &vectorcraft_text::FontFace, - id: NodeId, - byte: usize, - ch: char, -) -> (Vec, Option<(u32, u32)>) { - type Found = (Vec, Option<(u32, u32)>); +/// The tooltip of a glyph no character maps (Show › Entire Font): its name from the font, `gid N` +/// when it has none, its id, and the feature that gives it, if one does. +fn unencoded_tip(face: &vectorcraft_text::FontFace, gid: u32) -> String { + let name = face.glyph_name(gid).unwrap_or_else(|| format!("gid {gid}")); + let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &gid.to_string())]); + match face.feature_reaching(gid) { + Some(feature) => format!("{name} ({glyph}) · {}", String::from_utf8_lossy(&feature)), + None => format!("{name} ({glyph})"), + } +} + +/// The alternates of the character at `byte` of text `id` in its word, and the glyph it is drawn +/// with ([`vectorcraft_text::alternates_in_context`]). Kept in egui's temporary data until the +/// document, the character or the face changes, as [`super::character`] keeps the kerning at the +/// caret. +fn alternates_found(app: &VectorcraftApp, ui: &Ui, id: NodeId, byte: usize, face_id: u32) -> Option { + type Found = Option; let state = app.session.active(); - let key = state.map(|s| (s.uid, s.revision, id, byte, face.id())); + let key = state.map(|s| (s.uid, s.revision, id, byte, face_id)); let slot = ui.scope_id().with("gl-alternates-found"); if let Some(key) = key && let Some((cached_key, found)) = ui.ctx().data(|d| d.get_temp::<((u64, u64, NodeId, usize, u32), Found)>(slot)) @@ -439,20 +756,10 @@ fn alternates_and_drawn( { return found; } - let drawn = state - .and_then(|s| s.doc.node(id)) - .and_then(|node| match &node.kind { - NodeKind::Text(text) => Some(vectorcraft_text::layout(FontDb::global(), text)), - _ => None, - }) - .and_then(|layout| layout.glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len))) - .map(|g| (g.font_id, g.gid)); - let mut list = face.alternates(ch); - let default = face.glyph_for(ch); - if !list.is_empty() && default != 0 && !list.iter().any(|a| a.gid == default) { - list.insert(0, vectorcraft_text::GlyphAlternate { gid: default, feature: [0; 4] }); - } - let found = (list, drawn); + let found = state.and_then(|s| s.doc.node(id)).and_then(|node| match &node.kind { + NodeKind::Text(text) => vectorcraft_text::alternates_in_context(FontDb::global(), text, byte), + _ => None, + }); if let Some(key) = key { ui.ctx().data_mut(|d| d.insert_temp(slot, (key, found.clone()))); } @@ -806,6 +1113,163 @@ mod tests { assert_eq!(highlighted(&found, &t), [squares(&found)[5].center()], "the chosen glyph, after b, u2, u4 and u3"); } + /// Double-click the glyph cell at `at`. + fn double_click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2) { + double_click_in(ctx, app, at, vec2(280.0, 420.0)); + } + + /// Double-click the glyph cell at `at` in a panel `size` large. + fn double_click_in(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2, size: egui::Vec2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Time only moves forward in one egui context. + let mut time = ctx.input(|i| i.time) + 10.0; + let mut timed = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let screen = Some(Rect::from_min_size(Pos2::ZERO, size)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: screen, time: Some(time), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + timed(app, vec![egui::Event::PointerMoved(at)]); + for _ in 0..2 { + timed(app, vec![button(true)]); + timed(app, vec![button(false)]); + } + } + + /// The glyph ids of the text `id`, in the order of its characters. + fn glyph_ids(app: &VectorcraftApp, id: &serde_json::Value) -> Vec { + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + let mut glyphs = vectorcraft_text::layout(FontDb::global(), text).glyphs; + glyphs.sort_by_key(|g| g.byte); + glyphs.iter().map(|g| g.gid).collect() + } + + /// For the selected medial letter of an Arabic word the grid lists that form's alternates, and + /// a double-click applies the feature that draws one to that letter alone, as one undo step: + /// the word keeps its joining forms and the text its characters; undo takes it back. + #[test] + fn a_double_click_applies_a_feature_value_to_one_letter_of_a_joined_word() { + use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font, source_sans_gid}; + FontDb::global().add_font(joining_alternates_font().expect("the test font builds")); + let g = |c| u32::from(source_sans_gid(c).expect("a glyph")); + let (mut app, id) = editing("\u{628}\u{62A}\u{646}"); + app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]); + select(&mut app, 2, 4); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + choose(&ctx, &mut app, "Alternates for Current Selection"); + let found = shapes(&ctx, &mut app, vec![]); + let cells = squares(&found); + assert_eq!(cells.len(), 4, "the medial teh and its three alternates, not the isolated form's"); + assert_eq!(highlighted(&found, &t), [cells[0].center()], "the form the letter has is first and drawn"); + let undo = app.session.active().unwrap().history.undo.len(); + double_click(&ctx, &mut app, cells[2].center()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step; status: {}", app.ui.status); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "\u{628}\u{62A}\u{646}", "the text keeps its characters"); + let features: Vec<(String, Vec)> = text.runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect(); + assert_eq!( + features, + [("\u{628}".to_string(), vec![]), ("\u{62A}".to_string(), vec!["salt=2".to_string()]), ("\u{646}".to_string(), vec![])], + "the feature is on exactly that letter" + ); + assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none())); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('x'), g('M')], "the neighbours keep their initial and final forms"); + // The pointer leaves the cell: a hovered cell is painted as hovered, not as the drawn one. + shapes(&ctx, &mut app, vec![egui::Event::PointerMoved(Pos2::ZERO)]); + let found = shapes(&ctx, &mut app, vec![]); + assert_eq!(highlighted(&found, &t), [squares(&found)[2].center()], "the applied variant is the one drawn"); + // The default glyph takes the feature off again, and undo takes each step back. + double_click(&ctx, &mut app, squares(&found)[0].center()); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('x'), g('M')]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.runs.iter().all(|r| r.style.features.is_empty()), "undo restores the features"); + } + + /// A variant no feature draws is still enabled: it replaces the glyph (`text.setGlyphAlternate`). + #[test] + fn a_variant_no_feature_draws_replaces_the_glyph() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let (mut app, id) = editing("xay"); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + select(&mut app, 2, 2); + let ctx = egui::Context::default(); + choose(&ctx, &mut app, "Alternates for Current Selection"); + let cells = squares(&shapes(&ctx, &mut app, vec![])); + let face = FontDb::global().face(alternates_family(true), "Regular").expect("the test face"); + // The last cell is cv01's glyph c, which the style has no tag for. + double_click(&ctx, &mut app, cells[cells.len() - 1].center()); + assert_eq!(glyph_ids(&app, &id)[1], face.glyph_for('c')); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.runs.iter().any(|r| r.style.glyph_alternate.is_some())); + // A feature variant afterwards takes the choice away in the same step. + let found = shapes(&ctx, &mut app, vec![]); + let undo = app.session.active().unwrap().history.undo.len(); + double_click(&ctx, &mut app, squares(&found)[2].center()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone"); + } + + /// A positional alternate without a supported feature still goes through the engine's glyph + /// choice validation, even though the isolated character has different alternates. + #[test] + fn a_positional_variant_no_feature_draws_replaces_the_glyph() { + use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font, source_sans_gid}; + FontDb::global().add_font(joining_alternates_font().expect("the test font builds")); + let face = FontDb::global().face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face"); + let gid = u32::from(source_sans_gid('d').expect("a glyph")); + assert!(face.alternates('\u{646}').is_empty(), "the isolated noon has no alternates"); + assert!(!face.is_unlisted_alternate('\u{646}', gid), "cv01 reaches the glyph"); + for view in ["Alternates for Current Selection", "Entire Font"] { + let (mut app, id) = editing("\u{628}\u{646}\u{62A}"); + app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + set_cell_index(&mut app, 0); + let plain = glyph_ids(&app, &id); + select(&mut app, 2, 4); + let ctx = egui::Context::default(); + choose(&ctx, &mut app, view); + let at = if view == "Entire Font" { + entire_font_cell(&ctx, &mut app, &face, gid).0 + } else { + let cells = squares(&shapes(&ctx, &mut app, vec![])); + assert_eq!(cells.len(), 2, "the medial noon and its cv01 alternate"); + cells[1].center() + }; + let undo = app.session.active().unwrap().history.undo.len(); + double_click_in(&ctx, &mut app, at, if view == "Entire Font" { TALL } else { vec2(280.0, 420.0) }); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "{view}: {}", app.ui.status); + assert_eq!(glyph_ids(&app, &id)[1], gid, "{view}"); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "\u{628}\u{646}\u{62A}", "the characters stay unchanged"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), plain, "{view}: undo restores the word"); + } + } + + /// Show › Entire Font also lists the glyphs no character maps. + #[test] + fn entire_font_lists_the_unencoded_glyphs() { + use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font}; + FontDb::global().add_font(joining_alternates_font().expect("the test font builds")); + let (mut app, id) = editing("\u{628}"); + app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + let face = FontDb::global().face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face"); + let encoded = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let unencoded = face.unencoded_glyphs(); + assert!(unencoded.len() > 1000 && encoded == 4, "{} unencoded, {encoded} encoded", unencoded.len()); + let ctx = egui::Context::default(); + let all = texts(&ctx, &mut app); + assert!(all.iter().any(|s| s == &format!("{} glyphs", encoded + unencoded.len())), "{all:?}"); + assert!(face.glyph_name(unencoded[0]).is_some(), "the font names its glyphs"); + } + #[test] fn zoom_stops_at_the_ends() { assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1); @@ -868,4 +1332,359 @@ mod tests { app.ui.glyphs_preview = 99; assert_eq!(cell_index(&app), CELL_DEFAULT); } + /// The panel tall enough to draw every row of the grid, so each glyph has a cell. + const TALL: egui::Vec2 = vec2(280.0, 8000.0); + + fn tall_shapes(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, TALL)), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).collect() + } + + /// Each glyph preview with the colour it is drawn in, in the order of the grid. + fn inks(shapes: &[Shape]) -> Vec<(Rect, Color32)> { + fn walk(s: &Shape, out: &mut Vec<(Rect, Color32)>) { + match s { + Shape::Mesh(m) => { + let r = m.calc_bounds(); + if (r.width() - r.height()).abs() < 1.0 && r.width() >= 14.0 { + out.push((r, m.vertices.first().map_or(Color32::TRANSPARENT, |v| v.color))); + } + } + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = vec![]; + shapes.iter().for_each(|s| walk(s, &mut out)); + out + } + + /// What a double-click on glyph `gid` of `face` would do to the selection ([`Fit::pick`], [`Fit::why`]). + fn verdict(ctx: &egui::Context, app: &VectorcraftApp, face: &vectorcraft_text::FontFace, gid: u32) -> Result { + let mut out = Err(String::new()); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, TALL)), ..Default::default() }; + let mut frame = ctx.run_ui(raw, |ui| { + let fit = Fit::of(app, ui, face); + out = fit.pick(face, gid).ok_or_else(|| fit.why(face, gid)); + }); + frame.textures_delta.clear(); + out + } + + /// The cell of glyph `gid` in Show › Entire Font of `face`: the grid lists the characters, then + /// the glyphs no character maps. Its centre, and the colour its glyph is drawn in. + fn entire_font_cell(ctx: &egui::Context, app: &mut VectorcraftApp, face: &vectorcraft_text::FontFace, gid: u32) -> (Pos2, Color32) { + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let unencoded = face.unencoded_glyphs(); + let cells = inks(&tall_shapes(ctx, app)); + assert_eq!(cells.len(), chars + unencoded.len(), "every glyph has a cell"); + let at = chars + unencoded.iter().position(|g| *g == gid).expect("an unencoded glyph"); + (cells[at].0.center(), cells[at].1) + } + + /// The owner's Kufi Murabba PIIIX Heavy (`~/Library/Fonts`), in the app's font database; `None` + /// when it is not installed. A file that is there but gives no face is a failure. + fn kufi_heavy() -> Option> { + let path = std::path::Path::new(&std::env::var_os("HOME")?).join("Library/Fonts/KufiMurabbaPIIIX-Heavy.ttf"); + if !path.exists() { + eprintln!("skipped: {} is not installed", path.display()); + return None; + } + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{} exists but cannot be read: {e}", path.display())); + FontDb::global().add_font(bytes); + let face = + FontDb::global().face("Kufi Murabba PIIIX", "Heavy").unwrap_or_else(|| panic!("{} exists but its face is missing", path.display())); + assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", "Heavy"), "the installed font, not a fallback"); + Some(face) + } + + fn gid_named(face: &vectorcraft_text::FontFace, name: &str) -> u32 { + (0..face.glyph_count()).find(|g| face.glyph_name(*g).as_deref() == Some(name)).unwrap_or_else(|| panic!("{name} missing")) + } + + /// The word al-Khwarizmi ending in `letter`, in Kufi Murabba PIIIX Heavy. + fn khwarizmi_in_heavy(letter: char) -> (VectorcraftApp, serde_json::Value, usize) { + let word = format!("\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}{letter}"); + let (mut app, id) = editing(&word); + app.run("text.setRangeStyle", json!({"id": id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap(); + (app, id, word.len() - letter.len_utf8()) + } + + /// Applying each alternate of the final letter of a word in Kufi Murabba PIIIX Heavy, with the + /// letter selected as the Type tool selects it (the UTF-8 byte range of the character, the + /// last one of a right-to-left word), by a double-click on its cell in Alternates for Current + /// Selection and in Entire Font: the same glyph, one undo step, the rest of the word as it was. + /// Persian yeh, Arabic yeh and alef maksura each have their own `.fina.alt1` to `.fina.alt6`. + #[test] + fn a_final_letters_alternates_apply_from_alternates_and_from_entire_font() { + let Some(face) = kufi_heavy() else { return }; + for (letter, base) in [('\u{06CC}', "yehfarsi"), ('\u{064A}', "yeh"), ('\u{0649}', "alefmaksura")] { + let (mut app, id, last) = khwarizmi_in_heavy(letter); + let plain = glyph_ids(&app, &id); + let ends = plain.len() - 1; + assert_eq!(plain[ends], gid_named(&face, &format!("{base}.fina")), "{base}: the final form"); + select(&mut app, last, last + letter.len_utf8()); + assert_eq!(selected_char(&app), Some(letter), "{base}: the selection is the last letter"); + let alternates: Vec = (1..=6).map(|n| gid_named(&face, &format!("{base}.fina.alt{n}"))).collect(); + let ctx = egui::Context::default(); + for view in ["Alternates for Current Selection", "Entire Font"] { + for (n, alt) in alternates.iter().enumerate() { + choose(&ctx, &mut app, view); + let undo = app.session.active().unwrap().history.undo.len(); + let at = if view == "Entire Font" { + let (at, ink) = entire_font_cell(&ctx, &mut app, &face, *alt); + assert_eq!(ink, Tokens::get(&ctx).text_strong, "{base} {view}: {alt} is not dimmed for its own letter"); + at + } else { + let cells = squares(&shapes(&ctx, &mut app, vec![])); + assert_eq!(cells.len(), 7, "{base}: the final form and its six alternates"); + cells[n + 1].center() + }; + double_click_in(&ctx, &mut app, at, TALL); + let after = glyph_ids(&app, &id); + let mut want = plain.clone(); + want[ends] = *alt; + assert_eq!(after, want, "{base} {view} alt{}: only the last letter changes; status: {}", n + 1, app.ui.status); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "{base} {view} alt{}: one undo step", n + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), plain, "{base} {view}: undo"); + } + } + } + } + + /// A glyph of another letter is dimmed in Entire Font and a double-click changes nothing: the + /// tooltip's reason names the glyph's letter, where in a word it stands and the selected letter. + #[test] + fn a_glyph_that_cannot_go_on_the_selection_is_dimmed_and_says_why() { + let Some(face) = kufi_heavy() else { return }; + let glyph = gid_named(&face, "yehfarsi.fina.alt2"); + assert_eq!(glyph, 820, "the owner's glyph"); + for (letter, selected) in [('\u{064A}', "ي (U+064A)"), ('\u{0649}', "ى (U+0649)")] { + let (mut app, id, last) = khwarizmi_in_heavy(letter); + let plain = glyph_ids(&app, &id); + select(&mut app, last, last + letter.len_utf8()); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + let why = verdict(&ctx, &app, &face, glyph).err().expect("not applicable"); + assert_eq!(why, format!("yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is {selected}.")); + let (at, ink) = entire_font_cell(&ctx, &mut app, &face, glyph); + assert_eq!(ink, t.text_disabled, "{letter}: dimmed"); + let undo = app.session.active().unwrap().history.undo.len(); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(glyph_ids(&app, &id), plain, "{letter}: nothing changes"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + assert_eq!(app.ui.status, why, "the status says why"); + } + // The same glyph on Persian yeh applies. + let (mut app, id, last) = khwarizmi_in_heavy('\u{06CC}'); + select(&mut app, last, last + 2); + let ctx = egui::Context::default(); + let (at, ink) = entire_font_cell(&ctx, &mut app, &face, glyph); + assert_eq!(ink, Tokens::get(&ctx).text_strong); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(glyph_ids(&app, &id).last(), Some(&glyph)); + // Nothing selected, and a variant of the letter's other form. + select(&mut app, 0, 0); + assert_eq!(verdict(&ctx, &app, &face, glyph).err().as_deref(), Some("Select one character with the Type tool first.")); + let (_, ink) = entire_font_cell(&ctx, &mut app, &face, glyph); + assert_eq!(ink, Tokens::get(&ctx).text_disabled, "dimmed without a selection"); + select(&mut app, last, last + 2); + let medial = gid_named(&face, "yehfarsi.medi.alt1"); + assert_eq!( + verdict(&ctx, &app, &face, medial).err().as_deref(), + Some("yehfarsi.medi.alt1 is not an alternate of ی (U+06CC) as it stands in its word."), + "its own letter, another place in a word" + ); + } + + /// A glyph no feature reaches (`a.a`, `a.b` of a font with no feature for them) is applied by + /// Entire Font to the selected character as a glyph choice, not dimmed; one the font's naming + /// gives to another letter (`a.a` on `y`) stays dimmed, and so does every glyph with no + /// character selected. + #[test] + fn entire_font_applies_a_glyph_no_feature_reaches_to_its_letter() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let face = FontDb::global().face(alternates_family(true), "Regular").expect("the test face"); + let glyph = gid_named(&face, "a.a"); + assert!(face.feature_reaching(glyph).is_none() && face.glyph_origin(glyph).is_none(), "no feature reaches it"); + let (mut app, id) = editing("xay"); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + let plain = glyph_ids(&app, &id); + // The cell of the glyph and its colour: this font's grid is longer than the panel is tall, + // but the glyph is in the first rows. + let cell_of = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let at = chars + face.unencoded_glyphs().iter().position(|g| *g == glyph).expect("an unencoded glyph"); + let cells = inks(&tall_shapes(ctx, app)); + (cells[at].0.center(), cells[at].1) + }; + // Another letter, and nothing selected: dimmed, and a double-click changes nothing. + for (start, end) in [(2, 3), (0, 0)] { + select(&mut app, start, end); + assert!(verdict(&ctx, &app, &face, glyph).is_err()); + let (at, ink) = cell_of(&ctx, &mut app); + assert_eq!(ink, t.text_disabled, "{start}..{end}: dimmed"); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(glyph_ids(&app, &id), plain); + } + // The letter itself: enabled, one undo step, drawn with that glyph. + select(&mut app, 1, 2); + assert!(matches!(verdict(&ctx, &app, &face, glyph), Ok(Pick::Glyph(g)) if g == glyph)); + let (at, ink) = cell_of(&ctx, &mut app); + assert_eq!(ink, t.text_strong); + let undo = app.session.active().unwrap().history.undo.len(); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + assert_eq!(glyph_ids(&app, &id), [plain[0], glyph, plain[2]]); + } + + /// A presentation form picked in Entire Font (beh medial, U+FE92) with the caret before the last + /// letter would be a fixed shape that does not join the word: the letter (beh) is inserted, so + /// the word is shaped as if it were typed, and the status says so. + #[test] + fn a_presentation_form_inserts_its_letter_so_the_word_joins() { + let Some(face) = kufi_heavy() else { return }; + let (mut app, id, last) = khwarizmi_in_heavy('\u{06CC}'); + let typed = { + let word = "\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}\u{628}\u{6CC}"; + let (mut other, other_id) = editing(word); + other.run("text.setRangeStyle", json!({"id": other_id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap(); + glyph_ids(&other, &other_id) + }; + let gid_of = |c| face.chars().into_iter().find(|(ch, _)| *ch == c).expect("the character is in the font").1; + // Typed as the presentation form itself, the same word is shaped differently: it does not join. + let literal = { + let word = "\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}\u{FE92}\u{6CC}"; + let (mut other, other_id) = editing(word); + other.run("text.setRangeStyle", json!({"id": other_id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap(); + glyph_ids(&other, &other_id) + }; + eprintln!("typed with beh {typed:?}\ntyped with U+FE92 {literal:?}"); + assert_ne!(literal, typed, "the presentation form is not shaped like the letter"); + assert_eq!(base_letter(&face, '\u{FE92}', gid_of('\u{FE92}')), Some('\u{0628}'), "medial beh is a form of beh"); + assert_eq!(base_letter(&face, '\u{0628}', gid_of('\u{0628}')), None, "beh itself inserts as it is"); + let tip = encoded_tip(&face, '\u{FE92}', gid_of('\u{FE92}')); + assert!(tip.contains("(U+0628)"), "{tip}"); + select(&mut app, last, last); + let ctx = egui::Context::default(); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).collect::>(); + let cells = inks(&tall_shapes(&ctx, &mut app)); + let at = cells[chars.iter().position(|(c, _)| *c == '\u{FE92}').expect("the presentation form is in the font")].0.center(); + double_click_in(&ctx, &mut app, at, TALL); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.plain_text().ends_with("\u{645}\u{628}\u{6CC}"), "the letter is in the text: {}", text.plain_text()); + assert_eq!(glyph_ids(&app, &id), typed, "shaped as the word typed with beh"); + assert!(app.ui.status.starts_with("Inserted ب (U+0628)"), "{}", app.ui.status); + } + + #[test] + fn an_unlisted_choice_without_gsub_can_be_reset_from_the_default_cell() { + use vectorcraft_text::test_fonts::{UNLISTED_VARIABLE_FAMILY, unlisted_variable_font}; + let db = FontDb::global(); + db.add_font(unlisted_variable_font().expect("the test font builds")); + let face = db.face(UNLISTED_VARIABLE_FAMILY, "Regular").expect("the test face"); + let glyph = gid_named(&face, "a.a"); + let (mut app, id) = editing("a"); + app.run("text.setRangeStyle", json!({"id": id, "font": UNLISTED_VARIABLE_FAMILY})).unwrap(); + select(&mut app, 0, 1); + let plain = glyph_ids(&app, &id); + app.run("text.setGlyphAlternate", json!({"id": id, "start": 0, "gid": glyph})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [glyph]); + let ctx = egui::Context::default(); + choose(&ctx, &mut app, "Alternates for Current Selection"); + let cells = squares(&shapes(&ctx, &mut app, vec![])); + assert_eq!(cells.len(), 1, "the default remains available without any GSUB alternates"); + let undo = app.session.active().unwrap().history.undo.len(); + double_click(&ctx, &mut app, cells[0].center()); + assert_eq!(glyph_ids(&app, &id), plain); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [glyph]); + } + + #[test] + fn entire_font_applies_an_unlisted_glyph_at_the_selections_variable_axes() { + use vectorcraft_text::test_fonts::{UNLISTED_VARIABLE_FAMILY, unlisted_variable_font}; + let db = FontDb::global(); + db.add_font(unlisted_variable_font().expect("the test font builds")); + let base = db.face(UNLISTED_VARIABLE_FAMILY, "Regular").expect("the test face"); + let glyph = gid_named(&base, "a.a"); + let (mut app, id) = editing("a"); + app.run("text.setRangeStyle", json!({"id": id, "font": UNLISTED_VARIABLE_FAMILY, "fontAxes": {"wght": 650}})).unwrap(); + select(&mut app, 0, 1); + let target = selected_target(&app).expect("the selected character"); + let face = db.style_face(target.style).expect("the variable instance"); + assert_ne!(face.id(), base.id(), "a non-default setting has its own face id"); + let ctx = egui::Context::default(); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let at = chars + face.unencoded_glyphs().iter().position(|g| *g == glyph).expect("the unencoded glyph"); + let cells = inks(&tall_shapes(&ctx, &mut app)); + assert_eq!(cells[at].1, Tokens::get(&ctx).text_strong, "the instance's alternate is enabled"); + double_click_in(&ctx, &mut app, cells[at].0.center(), TALL); + assert_eq!(glyph_ids(&app, &id), [glyph]); + assert_eq!(selected_target(&app).unwrap().style.font_axes["wght"], 650.0); + app.run("edit.undo", json!({})).unwrap(); + assert_ne!(glyph_ids(&app, &id), [glyph]); + } + + #[test] + fn encoded_characters_normalize_only_arabic_presentation_forms() { + use vectorcraft_text::test_fonts::{ALIASED_JOINING_FAMILY, aliased_joining_font}; + let db = FontDb::global(); + db.add_font(aliased_joining_font().expect("the test font builds")); + let face = db.face(ALIASED_JOINING_FAMILY, "Regular").expect("the test face"); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).collect::>(); + for (c, expected) in [('B', "B"), ('\u{FE91}', "\u{0628}")] { + let (mut app, id) = editing("a"); + app.run("text.setRangeStyle", json!({"id": id, "font": ALIASED_JOINING_FAMILY})).unwrap(); + select(&mut app, 0, 1); + let at = chars.iter().position(|(ch, _)| *ch == c).expect("an encoded character"); + let origin = face.glyph_origin(chars[at].1).expect("the shared positional glyph"); + assert_eq!(origin.ch, '\u{0628}'); + assert!(origin.position().is_some()); + let ctx = egui::Context::default(); + set_pstate(&ctx, "gl-font", Some((ALIASED_JOINING_FAMILY.to_string(), "Regular".to_string()))); + let cells = inks(&tall_shapes(&ctx, &mut app)); + double_click_in(&ctx, &mut app, cells[at].0.center(), TALL); + let state = app.session.active().unwrap(); + let NodeKind::Text(text) = &state.doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), expected, "the encoded {c} cell inserts the intended character"); + app.run("edit.undo", json!({})).unwrap(); + let state = app.session.active().unwrap(); + let NodeKind::Text(text) = &state.doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "a"); + } + } + + #[test] + fn a_normalized_glyph_without_a_text_target_preserves_the_insertion_error() { + use vectorcraft_text::test_fonts::{ALIASED_JOINING_FAMILY, aliased_joining_font}; + let db = FontDb::global(); + db.add_font(aliased_joining_font().expect("the test font builds")); + let face = db.face(ALIASED_JOINING_FAMILY, "Regular").expect("the test face"); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).collect::>(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let undo = app.session.active().unwrap().history.undo.len(); + let ctx = egui::Context::default(); + set_pstate(&ctx, "gl-font", Some((ALIASED_JOINING_FAMILY.to_string(), "Regular".to_string()))); + let cells = inks(&tall_shapes(&ctx, &mut app)); + let at = |c| chars.iter().position(|(ch, _)| *ch == c).expect("the encoded glyph"); + double_click_in(&ctx, &mut app, cells[at('B')].0.center(), TALL); + let failed = app.ui.status.clone(); + assert!(!failed.is_empty(), "the ordinary character reports the missing text target"); + double_click_in(&ctx, &mut app, cells[at('\u{FE91}')].0.center(), TALL); + assert_eq!(app.ui.status, failed, "normalization must not overwrite the insertion failure with a success"); + let state = app.session.active().unwrap(); + assert_eq!(state.history.undo.len(), undo); + let mut texts = 0; + state.doc.walk(|n| texts += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(texts, 0, "neither glyph created or changed text"); + } } diff --git a/crates/ui-egui/src/panels/navigator.rs b/crates/ui-egui/src/panels/navigator.rs index da8048407b..60eb4c18d0 100644 --- a/crates/ui-egui/src/panels/navigator.rs +++ b/crates/ui-egui/src/panels/navigator.rs @@ -249,11 +249,12 @@ pub fn thumb_to_doc(region: DRect, thumb: Rect, p: egui::Pos2) -> Point { Point::new(region.x0 + (p.x as f64 - ox) / s, region.y0 + (p.y as f64 - oy) / s) } -/// The view box in the thumbnail: the corners of the canvas `canvas` showing view `v`, mapped -/// into the document (turned with the view) and then into the thumbnail, cut to the thumbnail -/// grown by a point. Fewer than three points when the view is outside the thumbnail. -fn view_box(region: DRect, thumb: Rect, canvas: Rect, v: &crate::state::View) -> Vec { - let xf = crate::canvas::Xf::new(canvas, v); +/// The view box in the thumbnail: the corners of the canvas `canvas` showing view `v` (with the +/// interface zoomed by `ui_zoom`), mapped into the document (turned with the view) and then into +/// the thumbnail, cut to the thumbnail grown by a point. Fewer than three points when the view is +/// outside the thumbnail. +fn view_box(region: DRect, thumb: Rect, canvas: Rect, v: &crate::state::View, ui_zoom: f32) -> Vec { + let xf = crate::canvas::Xf::at(canvas, v, ui_zoom); let mut pts: Vec = [canvas.left_top(), canvas.right_top(), canvas.right_bottom(), canvas.left_bottom()] .iter() .map(|c| doc_to_thumb(region, thumb, xf.to_doc(*c))) @@ -330,7 +331,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // The view box: the canvas's corners in the document, turned with the view, cut to the thumbnail. let view = app.view().copied(); if let (Some(v), Some(cr)) = (view, app.canvas_rect) { - let box_points = view_box(region, thumb, cr, &v); + let box_points = view_box(region, thumb, cr, &v, app.canvas_ui_zoom); if box_points.len() > 2 { let color = app.ui.navigator_panel.view_box(); ui.painter().with_clip_rect(thumb).add(egui::Shape::closed_line(box_points, Stroke::new(1.5, color))); @@ -449,6 +450,54 @@ mod tests { assert!(points.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "{points:?} is not {want:?}"); } + /// With the interface zoomed by Preferences › User Interface › UI Scaling, the view box still + /// outlines what the canvas shows: at 400% and UI Scaling 1.5×, a canvas 600 interface points + /// wide shows 600 × 1.5 / 4 document points. + #[test] + fn the_view_box_outlines_the_canvas_at_every_ui_scaling() { + use vectorcraft_engine::Session; + for ui_zoom in [0.75_f32, 1.5, 2.0] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let canvas = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)); + app.canvas_rect = Some(canvas); + app.canvas_ui_zoom = ui_zoom; + app.run("view.setZoom", json!({"zoom": 400, "center": [200, 150]})).unwrap(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let pasteboard = Tokens::get(&ctx).pasteboard; + let thumb = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == pasteboard => Some(r.rect), + _ => None, + }) + .expect("the thumbnail"); + let red = egui::epaint::ColorMode::Solid(Color32::from_rgb(0xff, 0x30, 0x30)); + let drawn: Vec> = out + .shapes + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Path(p) if p.closed && p.stroke.color == red => Some(p.points), + _ => None, + }) + .collect(); + let [points] = &drawn[..] else { panic!("UI Scaling {ui_zoom}×: one view box: {drawn:?}") }; + let region = CACHE.with(|c| c.borrow().region.unwrap().1); + // Each canvas corner is (its offset from the canvas's center) × UI Scaling / zoom + // document points from the view's center. + let k = f64::from(ui_zoom) / 4.0; + let want = [canvas.left_top(), canvas.right_top(), canvas.right_bottom(), canvas.left_bottom()].map(|c| { + let d = c - canvas.center(); + doc_to_thumb(region, thumb, Point::new(200.0 + f64::from(d.x) * k, 150.0 + f64::from(d.y) * k)) + }); + assert_eq!(points.len(), 4, "UI Scaling {ui_zoom}×: {points:?}"); + assert!(points.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "UI Scaling {ui_zoom}×: {points:?} is not {want:?}"); + } + } + /// The panel menu's Panel Options… is enabled, and a click opens the Navigator Panel Options. #[test] fn panel_options_opens_from_the_panel_menu() { diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 4b5d4054a3..64daf1ac76 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -44,8 +44,7 @@ impl Flag { fn put(self, f: &mut OtFeatures, on: bool) { match self { Flag::Field(field) => *field(f) = on, - Flag::Extra(tag) if on => f.extra |= extra_bit(tag), - Flag::Extra(tag) => f.extra &= !extra_bit(tag), + Flag::Extra(tag) => f.set_extra(extra_bit(tag), on), } } } @@ -69,13 +68,16 @@ pub(crate) fn figure(f: &OtFeatures) -> usize { fn set_figure(f: &mut OtFeatures, i: usize) { f.tabular_figures = i == 1 || i == 4; f.oldstyle_figures = i >= 3; - f.extra &= !match i { - 1 => extra_bit("pnum"), - 3 => extra_bit("lnum"), - _ => extra_bit("pnum") | extra_bit("lnum"), - }; + f.set_extra( + match i { + 1 => extra_bit("pnum"), + 3 => extra_bit("lnum"), + _ => extra_bit("pnum") | extra_bit("lnum"), + }, + false, + ); if i == 2 { - f.extra |= extra_bit("pnum") | extra_bit("lnum"); + f.set_extra(extra_bit("pnum") | extra_bit("lnum"), true); } } @@ -95,11 +97,7 @@ pub(crate) fn position(f: &OtFeatures) -> Option { /// Default Position turns all four off. fn set_position(f: &mut OtFeatures, i: usize) { for (k, tag) in POSITION_TAGS.iter().enumerate() { - if k + 1 == i { - f.extra |= extra_bit(tag); - } else { - f.extra &= !extra_bit(tag); - } + f.set_extra(extra_bit(tag), k + 1 == i); } } diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 6a984a1ced..c7b8e47828 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -346,7 +346,7 @@ fn place_above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, spa /// Screen points per point of text `n` at the canvas zoom, as [`above_text`] sizes the ruler: /// across the frame for area type, the zoom for point type. fn screen_scale(app: &VectorcraftApp, n: &Node, span: f64) -> Option { - let xf = crate::canvas::Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let per = match &n.kind { NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => f64::from(xf.rect_to_screen(n.geometric_bounds()?).width()) / span, _ => xf.zoom, @@ -358,7 +358,7 @@ fn screen_scale(app: &VectorcraftApp, n: &Node, span: f64) -> Option { /// put 0 on the text's left edge and its marks at the canvas zoom, so each stop sits over the /// column it sets (#729). `span`: the ruler's length in the text's points. fn above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, span: f64) -> Option<()> { - let xf = crate::canvas::Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let on_screen = xf.rect_to_screen(n.geometric_bounds()?); // Screen points per point of the text: across the frame for area type (so a scaled frame // lines up too), the zoom for point type. diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index de9407e2f3..1169422789 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -451,7 +451,7 @@ impl VectorcraftApp { let at = match pos { _ if io::opens_as(name).is_some() || fileio::opens_with_importer(name) => return DropTarget::Open, Some(p) if !rect.contains(p) => return DropTarget::Open, - Some(p) => crate::canvas::Xf::new(rect, v).to_doc(p), + Some(p) => crate::canvas::Xf::at(rect, v, self.canvas_ui_zoom).to_doc(p), None if is_document(name) => return DropTarget::Open, None => v.center, }; diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index f3a201124b..e7c64463de 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -449,17 +449,17 @@ fn fixed_entries() -> &'static (Vec, Vec) { .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true }) .collect(); let mut seen = HashSet::new(); + // The commands listed only because an item of the menu bar runs them as a key press would + // (Set Corners…). + let mut by_the_menu_bar = HashSet::new(); for c in vectorcraft_engine::command_specs() { - // Commands outside the menus (panel menu items) are - // listed when a key alone can run them; queries and aliases never are. - let listed = if c.menu.is_empty() { - c.shortcut.is_some() || (c.journal && !vectorcraft_engine::cmd::is_alias(c.id) && runs_without_params(c.params)) - } else { - true - }; - if !listed || !seen.insert(c.id) { + let own = listed_on_its_own(c); + if !(own || a_menu_bar_item_runs(c.id, c.params)) || !seen.insert(c.id) { continue; } + if !own { + 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 }); } @@ -472,13 +472,36 @@ fn fixed_entries() -> &'static (Vec, Vec) { // Each effect of the Effect menu comes next, by its own key (#3296): see [`snapshot`]. let mut tail = vec![]; for (id, label, sc, params) in menus::UI_COMMANDS { - // Rename has no default key, and a key press names the selection: it needs no `id`. - let key_runs_it = !sc.is_empty() || params.starts_with("{}") || params.starts_with("{path") || *id == "ui.rename"; - if !seen.insert(id) || !key_runs_it { + let own = ui_listed_on_its_own(id, sc, params); + if !seen.insert(id) || !(own || a_menu_bar_item_runs(id, params)) { continue; } + 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 }); } + // 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. + 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()) { + if new_names.contains(&name(e)) + && !by_the_menu_bar.contains(e.key.as_str()) + && vectorcraft_engine::find_command(&e.key).is_some() + && !menu_bar_items().contains_key(e.key.as_str()) + { + e.group = id_group(&e.key); + } + } + let names: HashSet<_> = v.iter().chain(&tail).filter(|e| !by_the_menu_bar.contains(e.key.as_str())).map(name).collect(); + let keep = |e: &Entry| !by_the_menu_bar.contains(e.key.as_str()) || !names.contains(&name(e)); + v.retain(keep); + tail.retain(keep); (v, tail) }) } @@ -721,17 +744,11 @@ fn dimmed(app: &VectorcraftApp, id: &str) -> Result<(String, Value), String> { fn needs_a_value(key: &str) -> bool { static KEYS: OnceLock> = OnceLock::new(); KEYS.get_or_init(|| { - // A menu item that runs the command itself with params of its own counts only when the - // params doc names no required field: Edit › Find Next gives `find` empty, which it rejects. - let runs = |c: &vectorcraft_engine::CommandSpec| { - menu_bar_items().get(c.id).is_some_and(|ps| ps.iter().any(|(_, p)| is_bare(p))) - || menu_target(c.id).is_some_and(|(id, _)| id != c.id || !names_a_required_field(c.params)) - }; vectorcraft_engine::command_specs() .iter() .filter(|c| { c.shortcut.is_none() - && !runs(c) + && !a_menu_bar_item_runs(c.id, c.params) && !crate::dialogs::variables::opens(c.id) && (!c.journal || NEEDS_A_PARAM.contains(&c.id) || (!c.menu.is_empty() && names_a_required_field(c.params))) }) @@ -741,6 +758,31 @@ fn needs_a_value(key: &str) -> bool { .contains(key) } +/// Does an item of the menu bar run command `id`, whose params doc is `params`, as a key press runs +/// it: with no params, or with the one set of params all its items give ([`menu_target`])? A menu +/// item that runs the command itself with params of its own counts only when the params doc names +/// no required field: Edit › Find Next gives `find` empty, which it rejects. +fn a_menu_bar_item_runs(id: &str, params: &str) -> bool { + menu_bar_items().get(id).is_some_and(|ps| ps.iter().any(|(_, p)| is_bare(p))) + || menu_target(id).is_some_and(|(target, _)| target != id || !names_a_required_field(params)) +} + +/// Is engine command `c` listed in Edit › Keyboard Shortcuts whether or not an item of the menu bar +/// runs it? A command with a menu path of its own is. One without (a panel menu item) is when a key +/// alone can run it: it has a default key, or it is an undoable command, not an alias, whose params +/// doc ends each field with `?`. +fn listed_on_its_own(c: &vectorcraft_engine::CommandSpec) -> bool { + !c.menu.is_empty() || c.shortcut.is_some() || (c.journal && !vectorcraft_engine::cmd::is_alias(c.id) && runs_without_params(c.params)) +} + +/// Is UI command `id` (default key `sc`, params doc `params`) listed in Edit › Keyboard Shortcuts +/// whether or not an item of the menu bar runs it? It is when it has a default key or a params doc +/// of `{}` or `{path…}`. Rename has no default key, and a key press names the selection: it needs +/// no `id`. +fn ui_listed_on_its_own(id: &str, sc: &str, params: &str) -> bool { + !sc.is_empty() || params.starts_with("{}") || params.starts_with("{path") || id == "ui.rename" +} + /// Does a menu bar item run the command with no params, so that it does what its label says and opens /// no dialog? A command no menu bar item runs (the Crop Image item runs `ui.cropImage`), a menu item /// that gives it params, a [`NEEDS_A_PARAM`] command and a field a call must give each rule it out. @@ -2378,6 +2420,70 @@ mod tests { assert_eq!(effective_in(&ov, "object.isolate").as_deref(), Some("Cmd+Alt+Shift+F11")); } + /// Every engine or UI command that an item of the menu bar runs as a key press runs it (without + /// params, or with the one set of params all its items give) is listed in Edit › Keyboard + /// Shortcuts, also when the command has no menu path of its own: Set Corners…, whose params doc + /// names a field a call must give, the Perspective Grid toggles, Edit Custom Dictionary…, a + /// query, and Recolor Artwork…, a UI command. Such a command is listed in the group of its id, + /// next to the commands of its family that were listed before. A shortcut given to one survives + /// a saved set's round trip. Find Next is left out: its menu item gives `find` empty, which the + /// command rejects. When another entry has the same group and label, the command the menu item + /// runs keeps the name if no menu item runs the other: File › Export Selection… is + /// `file.exportSelection`, and `document.exportSelection` moves to Document. Otherwise the + /// command is left out: Window › Learn is the Learn panel's entry, not `window.learn`. + #[test] + fn every_command_the_menu_bar_runs_can_be_given_a_shortcut() { + let none = Overrides::new(); + let mut missing: Vec<&str> = menu_bar_items() + .iter() + .filter(|(id, items)| items.iter().any(|(_, p)| is_bare(p)) || menu_target(id).is_some()) + .map(|(id, _)| *id) + .filter(|id| *id != "edit.findNext" && *id != "window.learn") + .filter(|id| vectorcraft_engine::find_command(id).is_some() || menus::UI_COMMANDS.iter().any(|c| c.0 == *id)) + .filter(|id| !entry(id).is_some_and(|e| listed(&none, &e))) + .collect(); + missing.sort_unstable(); + assert!(missing.is_empty(), "{missing:?}"); + assert!(entry("window.learn").is_none(), "Window › Learn is the Learn panel's entry"); + let shown = |id: &str| entry(id).map(|e| (e.group, e.label)); + assert_eq!(shown("file.exportSelection"), Some(("File".into(), "Export Selection…".into()))); + assert_eq!(shown("document.exportSelection"), Some(("Document".into(), "Export Selection…".into()))); + // No command listed only because an item of the menu bar runs it shares its group and label + // with another entry. + let own = |k: &str| { + vectorcraft_engine::command_specs() + .iter() + .find(|c| c.id == k) + .map(listed_on_its_own) + .or_else(|| menus::UI_COMMANDS.iter().find(|c| c.0 == k).map(|(id, _, sc, p)| ui_listed_on_its_own(id, sc, p))) + .unwrap_or(true) + }; + let (v, tail) = fixed_entries(); + let mut names: HashMap<(&str, &str), Vec<&str>> = HashMap::new(); + for e in v.iter().chain(tail) { + names.entry((e.group.as_str(), e.label.as_str())).or_default().push(e.key.as_str()); + } + let shared: Vec<_> = names.iter().filter(|(_, keys)| keys.len() > 1 && keys.iter().any(|k| !own(k))).collect(); + assert!(shared.is_empty(), "{shared:?}"); + for (id, group) in [ + (crate::addons::pathtools::set_corners::COMMAND, "Addon"), + ("perspective.grid.snap", "Perspective"), + (crate::dialogs::custom_dictionary::COMMAND, "Text"), + ("ui.recolorDialog", "Ui"), + ] { + let e = entry(id).unwrap_or_else(|| panic!("{id} is listed")); + assert_eq!(e.group, group, "{id}"); + } + for id in + [crate::addons::pathtools::set_corners::COMMAND, "perspective.grid.lock", crate::dialogs::custom_dictionary::COMMAND, "ui.recolorDialog"] + { + let mut ov = Overrides::new(); + assign(&mut ov, id, Some("Cmd+Alt+Shift+F11"), false).unwrap(); + let (_, back) = import_json(&export_json("Mine", &ov)).unwrap(); + assert_eq!(effective_in(&back, id).as_deref(), Some("Cmd+Alt+Shift+F11"), "{id}"); + } + } + /// `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/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 785fca82e2..e002d07adf 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -294,9 +294,13 @@ fn a_shortcut_runs_what_its_menu_item_runs() { /// Commands whose menu item opens a slider or a dialog before anything changes: the sliders of /// Smooth and Simplify, Offset Path, the Object › Transform dialogs, Average, Split Into Grid, -/// Vector Halftone, Rearrange All Artboards, Pathfinder Options, Arrange in Grid, Divide into -/// Dashes, Find Nearest Swatch, and four "…" items whose menu entry gives params. -const MENU_DIALOG_COMMANDS: [&str; 22] = [ +/// Vector Halftone, Rearrange All Artboards, Pathfinder Options, the Object › Plug-ins dialogs +/// (Arrange in Grid, Replace with Top Object, the size, opacity and sort dialogs of the Arrange +/// add-on, Set Corners…, Divide into Dashes, Find Nearest Swatch, the swatch legend, the type +/// tools, the select add-on and Select by Link Status…), four "…" items whose menu entry gives +/// params, Edit Custom Dictionary…, Composite Fonts…, Recolor Artwork…, the Edit menu's preset +/// dialogs and Apply SVG Filter…. +const MENU_DIALOG_COMMANDS: [&str; 39] = [ "object.path.smooth", "object.path.simplify", "object.path.offsetPath", @@ -313,12 +317,29 @@ const MENU_DIALOG_COMMANDS: [&str; 22] = [ "artboard.rearrange", "object.pathfinder.options", "addon.arrange.grid", + "addon.arrange.replace", + "addon.arrange.size", + "addon.arrange.randomizeOpacity", + "addon.arrange.sort", + crate::addons::pathtools::set_corners::COMMAND, crate::addons::pathtools::dashes::COMMAND, crate::addons::color::nearest::COMMAND, + crate::addons::color::legend::COMMAND, + crate::addons::typetools::COMMAND, + crate::addons::select::COMMAND, + crate::addons::select::link_status::COMMAND, "object.path.cleanUp", "object.mesh.create", "type.smartPunctuation", "edit.findReplace", + crate::dialogs::custom_dictionary::COMMAND, + crate::dialogs::composite_fonts::COMMAND, + "ui.recolorDialog", + "ui.flattenerPresetsDialog", + "ui.printPresetsDialog", + "ui.pdfPresetsDialog", + "ui.perspectivePresetsDialog", + "ui.svgFilterDialog", ]; /// F pressed without a modifier. @@ -342,8 +363,33 @@ fn undo_len(app: &VectorcraftApp) -> usize { app.session.active().unwrap().history.undo.len() } +/// View › Perspective Grid › Snap to Grid, whose command has no menu path of its own, turns off and +/// on again from a shortcut given to it, as its menu item does. +#[test] +fn a_shortcut_on_a_perspective_grid_toggle_runs_it() { + let snaps = |app: &VectorcraftApp| crate::menus::checked(app, "perspective.grid.snap", &serde_json::Value::Null); + let mut app = app_with_shortcut("perspective.grid.snap", "F"); + assert_eq!(snaps(&app), Some(true), "on by default"); + frame(&mut app, press_f()); + assert_eq!(snaps(&app), Some(false), "F turns it off: {}", app.ui.status); + frame(&mut app, vec![]); + frame(&mut app, press_f()); + assert_eq!(snaps(&app), Some(true), "and on again: {}", app.ui.status); +} + +/// A shortcut on File › Export Selection… does what a click on the menu item does: it adds the +/// selection to Export for Screens' assets and opens Export for Screens. +#[test] +fn a_shortcut_on_export_selection_opens_export_for_screens() { + let mut app = app_with_shortcut("file.exportSelection", "F"); + frame(&mut app, press_f()); + assert_eq!(open_dialog(&app).as_deref(), Some("exportForScreens"), "{}", app.ui.status); +} + /// A command given a key without a modifier runs what its menu item runs, as it does on a chord: -/// its slider or dialog opens and nothing changes until it is confirmed. +/// its slider or dialog opens and nothing changes until it is confirmed. The dialog is the one a +/// click on the command's menu item opens (its item without params, else its first item), also for +/// commands without a menu path of their own (Set Corners…, Recolor Artwork…). #[test] fn a_single_key_shortcut_opens_the_dialog_its_menu_item_opens() { let chord = vec![ @@ -364,8 +410,17 @@ fn a_single_key_shortcut_opens_the_dialog_its_menu_item_opens() { }; let by_key = opened("F", press_f()); let by_chord = opened("Cmd+Alt+Shift+F9", chord.clone()); - if by_key.is_none() || by_key != by_chord { - failures.push(format!("{id}: F opens {by_key:?}, the chord {by_chord:?}")); + let mut app = app_with_a_selected_rectangle(); + // Type › Composite Fonts… shows while East Asian options are shown. + app.session.prefs.show_east_asian_options = true; + let items: Vec<_> = crate::menus::menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some(id)).collect(); + let item = items.iter().find(|e| e.params.as_object().is_none_or(|o| o.is_empty())).or(items.first()); + // Pathfinder Options… is an item of the Pathfinder panel's menu, which runs it without params. + let (menu_id, p) = item.map_or_else(|| (id.to_string(), json!({})), |item| crate::menus::click_target(&item.label, id, &item.params)); + crate::menus::invoke(&mut app, &menu_id, p); + let by_menu = app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.get("__command").cloned())); + if by_key.is_none() || by_key != by_chord || by_chord != by_menu { + failures.push(format!("{id}: F opens {by_key:?}, the chord {by_chord:?}, the menu item {by_menu:?}")); } } assert!(failures.is_empty(), "{failures:#?}"); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4a0e73da6b..5f22839cc5 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -9,7 +9,9 @@ use crate::theme::Brightness; /// Per-document view (camera). #[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub struct View { - /// Screen points per document point (1.0 = 100%). + /// The zoom (1.0 = 100%): points of the screen per document point, counted before + /// Preferences › User Interface › UI Scaling, which scales the interface and not the art. + /// [`Self::screen_zoom`] is the zoom in the interface's points. pub zoom: f64, /// Document point at the centre of the canvas. pub center: Point, @@ -55,6 +57,19 @@ impl View { outline: false, }) } + + /// The interface's points per document point when the interface is zoomed by `ui_zoom` (egui's + /// zoom factor, which Preferences › User Interface › UI Scaling sets). A document point then + /// covers the same device pixels at every UI Scaling. + pub fn screen_zoom(&self, ui_zoom: f32) -> f64 { + self.zoom / f64::from(ui_zoom) + } + + /// This view with its zoom in the interface's points ([`Self::screen_zoom`]), as the canvas + /// renders it. + pub(crate) fn on_screen(&self, ui_zoom: f32) -> Self { + Self { zoom: self.screen_zoom(ui_zoom), ..*self } + } } /// The status bar's angle menu and View › Rotate View list these preset view angles, each as @@ -590,6 +605,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, @@ -1094,6 +1119,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/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/docs/control-protocol.md b/docs/control-protocol.md index 71d112e34b..d1f1fce598 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,6 +1,6 @@ # Control protocol -> **Last updated:** 2026-10-31 · **Change:** Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). +> **Last updated:** 2026-11-07 · **Change:** text style commands take a feature with a value (`"features": ["salt=2"]`), the font's alternate number, and shape runs that differ only in features as one piece of text; previously: Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). `vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number from 1 to 65535 (`--control=` works too), given once; otherwise the app prints an error and exits @@ -987,6 +987,23 @@ handle); a Mesh tool click inside a mesh envelope adds a row and a column. While selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command. +## OpenType feature values in text commands + +`text.setRangeStyle`, `text.setStyle` and the character style commands take OpenType features as a list of tags in +`features` (the whole list) or `featureChanges` (changes on top of each character's own): `"dlig"` on, `"-liga"` off. +A feature with alternates (`swsh`, `ss01`–`ss20`, `salt`, `titl`, `pnum`, `lnum`, `sups`, `subs`, `numr`, `dnom`, +`aalt`) also takes `tag=N`, `N` from 0 to 65535, the font's alternate number: `"salt=2"` draws the second alternate of +each glyph in the range that has one. It is the value the shaper gets (an OpenType alternate substitution picks the +glyph at that position of its set), so `"salt"` is `"salt=1"`, and `"-salt"`, `"salt=0"` and a missing tag are off. A +later value replaces an earlier one; `salt=x`, `salt=65536`, `-salt=2` and `liga=2` are refused. The style's +`features` list and the saved file keep the string as typed (`["salt=2"]`), and SVG export writes CSS +`font-feature-settings: "salt" 2`. Runs that differ only in `features` are shaped as one piece of text with each run's +features over its own characters, so one letter of an Arabic word with `salt=2` keeps its neighbours' joining forms. + +```json +{"id":1,"method":"engine.execute","params":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"features":["salt=2"]}}} +``` + ## Shared panel docking The canvas and the panels form one craft-ui docking layout: the protected `canvas` panel beside a dock at the @@ -1082,6 +1099,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Documented OpenType feature values in text commands (`"salt=2"`, the font's alternate number) and that runs differing only in features shape as one piece of text. | | 2026-10-31 | minor | Confining file access: on Windows a `:` in a name after the drive (an NTFS alternate data stream) is refused with the device names (#3604). | | 2026-10-25 | minor | Panel docking on craft-ui's layout: `ui.docking`, the floating and dock parameters, native floating windows. | | 2026-10-25 | minor | Documented that Object → Expand… is dimmed, and `ui.expandDialog` refuses, while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089). | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 24d442d0c4..5f0b7baccd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **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; 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; 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) 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 +20,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 | @@ -113,7 +111,17 @@ private editing data. VectorCraft: Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag and Gaussian Blur too). Styles with gradient or pattern fills, with fills, strokes or effects the reader doesn't read back, with a name another style has, with names over 1,024 bytes and past 4,096 styles are left out with a warning. A panel with no style written opens with a new document's - styles, with a warning. No art style is written on a large canvas. + styles, with a warning. No art style is written on a large canvas. The Brushes panel's Calligraphic brushes, and its + Scatter and Art brushes with their art, are written in its order: Scatter and Art brushes in Tints or keeping their + art's colors, Art brushes that stretch their art along the path from left to right at 100% unflipped without + overlap, and art of paths and compound paths with points and groups with members, with one solid fill and one + plain stroke each. Other brushes, + Pattern and Bristle brushes among them, and brushes past the number or the art VectorCraft's reader reads are left + out with a warning; no brush is written on a large canvas. A path or compound path with a Calligraphic brush, or + with an Art brush in Tints whose art bends along the path, keeps the brush on its stroke; other brushed strokes are + written as their brush's art. A document that uses a new document's brushes writes none until a path keeps one on + its stroke; then the brushes are written, and the file opens without the new document's Pattern and Bristle brushes + and its Art brush with other options. Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, @@ -241,42 +249,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. @@ -347,6 +320,7 @@ 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 | 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). | | 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | diff --git a/docs/gaps.md b/docs/gaps.md index d7fa66f613..09c31c8ae8 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 (#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-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) ## Feature submission evidence @@ -384,6 +384,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G4 Advanced type +- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them; a double-click on one applies it to the selected character when the font gives it as that character's alternate in its word (`yehfarsi.fina.alt2` on a final Persian yeh), and a glyph no feature reaches whose name does not belong to another letter (`A.salt`, `s.ss1` in a font with no such feature) is applied to the selected character as a glyph choice, which may not join; any other is drawn dimmed with a tooltip that says why (it belongs to another letter or place in a word, nothing is selected). An Arabic presentation form picked there inserts its letter, so the word still joins. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged. + - **Fixed:** fullwidth `!` / `?` stay upright in vertical point and area type when a font has no vertical alternate. Existing `vert` substitutions and the rotated fallback for brackets and long vowel marks are preserved; the broader CJK gap and estimate are unchanged. - **Done:** character-style Auto-leading resets propagate to style users while retaining local leading overrides. Sparse definitions remain sparse, native serialization retains explicit Auto, and the Character Styles override marker stays consistent. @@ -592,7 +594,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi On Windows with Windows Ink, the pen's tilt and barrel rotation where the pen reports them (#372; not yet tried with a pen). - **Missing:** pen pressure on native Wayland (#491, #764) — winit 0.30 reports none there; tilt, bearing and - barrel rotation on native Wayland and the web, and for Art, Pattern and Bristle brushes (6D Art Pen, #372); + barrel rotation on native Wayland and the web, and for Pattern and Bristle brushes (6D Art Pen, #372); the eraser end on native Wayland. - **Estimate:** 4–10 h, partly waiting on winit 0.31; needs pen hardware to verify. @@ -859,6 +861,8 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 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. | | 2026-11-03 | minor | An imported document's own title (an SVG's root ``, a PDF's XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name stays the file's name (#3626); no scored gap or estimate reassessed. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index ce9f55cf50..31a218eb6c 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -22,7 +22,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and | Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done | | Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done | | Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | yes, read from AppKit's tablet events before the window gets them (winit reports none), each move of a stroke with its own pressure, mouse event coalescing off (#852; not yet tried on a Mac) | yes (pointer force via `WM_POINTER`) | under X11 and XWayland (Preferences › Performance › Linux Display Server, #1151, #2028; Automatic uses XWayland when a pen is attached on a Wayland session, #4967), read from XInput2 raw events (#764); **no** on native Wayland, where winit reports none and the pen may not reach the window (#491) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate after a pen contact until reload) | partial: 2–4 h for Wayland (winit 0.31), plus a check on a Mac | -| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | with Windows Ink, read from the pen's `POINTER_PEN_INFO` (tilt and barrel rotation where its pen mask says the pen reports them); Calligraphic and Scatter brushes vary with them (#372; not yet tried with a pen) | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: native Wayland and the web read none, and Art, Pattern and Bristle brushes do not use them; 3–6 h (#372) | +| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | with Windows Ink, read from the pen's `POINTER_PEN_INFO` (tilt and barrel rotation where its pen mask says the pen reports them); Calligraphic and Scatter brushes vary with them (#372; not yet tried with a pen) | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: native Wayland and the web read none, and Pattern and Bristle brushes do not use them (an Art brush's Width follows them); 3–6 h (#372) | | Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | with Windows Ink, an inverted pen or its eraser button (`PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER` in the pen's pointer messages) selects the Eraser tool and the tip brings back the tool used before, as on Linux (not yet tried with a pen) | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | a press of the eraser end (Pointer Events eraser button, `button` 5) selects the Eraser tool, and that press erases; a press of the tip brings back the tool used before; Pointer Events report no end while the pen hovers | partial | | Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch, scroll and rotate | pinch, pan (a second finger cancels the first finger's tool press, #4198); two-finger tap undo, three-finger redo (#585) | same where winit reports touch | eframe until the first pen contact, then Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and touch-screen rotation missing, 3–5 h | | Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | diff --git a/docs/mcp.md b/docs/mcp.md index 672e730095..62a4212472 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1956,8 +1956,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`, @@ -2870,16 +2871,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 +2895,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 +2997,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 @@ -4872,6 +4892,30 @@ They list such a command while the user has changed its shortcut (`overridden: t shortcut for that command runs what a click on the item runs: Object › Path › Clean Up…, Print Ink Fixes… and Edit › Find and Replace… open their dialogs. +A command without a menu path of its own is listed when an item of the menu bar runs it with no params, or when every +item that runs it gives it the same params. It is listed in the group named by the first part of its id. These +commands are listed this way: + +- `addon.pathtools.cornerSet` (Object › Plug-ins › Path Precision Tools › Set Corners…, which opens its dialog), + under Addon; +- `perspective.grid.lock`, `perspective.grid.lockStation`, `perspective.grid.rulers` and `perspective.grid.snap` + (View › Perspective Grid), and `perspective.attach`, `perspective.editText` and `perspective.plane.matchObject` + (Object › Perspective), under Perspective; +- `text.spelling.dictionary` (Edit › Edit Custom Dictionary…), under Text; +- the UI commands `ui.recolorDialog` (Edit › Edit Colors › Recolor Artwork…), `ui.flattenerPresetsDialog`, + `ui.printPresetsDialog`, `ui.pdfPresetsDialog`, `ui.perspectivePresetsDialog`, `ui.svgFilterDialog`, + `ui.saveSwatchLibrary`, `ui.saveBrushLibrary`, `ui.saveGraphicStyleLibrary` and `ui.saveSymbolLibrary`, under Ui; + `file.exportAs` and `file.exportSelection`, under File; `links.editOriginal`, under Links; `type.applyBullets` and + `type.applyNumbers`, under Type; and `window.toolbar.new`, `window.toolbarColumns`, `window.toolsBesideCanvas`, + `window.workspace.new`, `window.arrange.floatInWindow` and `window.arrange.consolidateAllWindows`, under Window. + +When such a command has the same group and label as an engine command that no item of the menu bar runs, the +command the menu item runs keeps the name, and the engine command is listed in the group named by the first part of +its id: File › Export Selection… runs `file.exportSelection`, as the menu item does, and `document.exportSelection` +is listed as Document › Export Selection…. A shortcut already given to `document.exportSelection` keeps running it. +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. + 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 @@ -5103,11 +5147,16 @@ control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home s off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by default: on, a click away from a panel popped out of the icon column puts it away), `displayPrintSize` (off by default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills -96 screen points, an inch at the system's reference density whatever the display scaling). The tool shortcut +96 screen points, an inch at the system's reference density whatever the display scaling). `uiScaling` scales the +interface and not the art: a zoom shows the art at the same size at every UI Scaling. The tool shortcut preference `holdToolKeys` also needs the desktop app. It is on by default: a single-key tool shortcut held for 0.25 s or more, or held while its tool is used, chooses the tool until the key is let go and then returns to the tool active before, after any drag in progress; a quicker press chooses the tool for good. With it off, every press and every key -repeat chooses the tool. `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features +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` @@ -5472,7 +5521,11 @@ value outside that range is read as the nearer end), `previewMode` and `alignNew `file.newPresets {category?}` lists the categories and presets (Recent: the last sizes used; Saved: the user's presets, kept in the preferences by `file.newPresets.save` and removed by `file.newPresets.delete`). A listed preset can be passed straight back to `file.new`. Print presets and sizes without `units` start in `unitsGeneral`; screen presets -(mobile, web, video, social) in Pixels. A saved or Recent preset whose size (up to 163,830 pt), `artboards` (1 to +(mobile, web, video, social) in Pixels. `file.new` and `artboard.new` take the same artboard sizes: each side more than 0 +and at most 3,999,856 pt (the 4,000,000 pt the canvas reaches from the origin, less a 72 pt bleed on both sides). A side outside that range is +refused with `width and height must be more than 0 and at most 3999856 pt`, followed by the limit in the document's +units in parentheses when they are not points, such as `(1411.0603 m)`, and no document or artboard is made. +A saved or Recent preset whose size (up to 3,999,856 pt), `artboards` (1 to 1,000), spacing (0 to 100,000 pt) or bleed (0 to 72 pt a side) lies outside those ranges is read with the nearer end of each range, and a size of 0 or less is read as 1 pt. The desktop app's `file.newDialog` (Cmd/Ctrl+N) opens the New Document dialog on the first Recent entry, or on the @@ -8003,20 +8056,23 @@ it, as with any style change to one character (`text.setRangeStyle`). The type s ## Alternate glyphs The Glyphs panel's Show › Alternates for Current Selection lists the alternate glyphs of the character selected with -the Type tool (a range's first character, else the one before the caret), with the default glyph first when the font -does not list it, and a double-click on one draws that character with it. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default, +the Type tool (a range's first character, else the one before the caret) as it stands in its word, with the glyph the +shaper makes for it first, and a double-click on one applies the OpenType feature that draws it to that character alone +(see [Alternates as OpenType feature values](#alternates-as-opentype-feature-values)); a glyph no feature draws there is +drawn by `text.setGlyphAlternate`. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default, chosen, alternates: [{gid, feature}]}`, where `default` is the glyph the font's character map gives, `chosen` the glyph drawn now, and `alternates` the glyphs the font's `GSUB` single and alternate substitutions put in place of the default glyph: `aalt` first, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and `titl`, in lookup order within each, each glyph once. The default glyph is listed only when `aalt` lists it. Ligatures and contextual substitutions are not listed. `text.setGlyphAlternate {id, start, gid}` draws the character with glyph -`gid`, one of those alternates or the default glyph, as one undo step; `gid: null` takes the choice away, and the +`gid`, one of those alternates, a glyph that no character maps and no feature reaches whose name is not another letter's +(`a.salt` for `a`, in a font with no `salt`), or the default glyph, as one undo step; `gid: null` takes the choice away, and the character is drawn as its style draws it (a feature such as `salt` can draw another glyph than the default). The text keeps the character and its other attributes, and characters typed next to it take their default glyphs. The choice is saved with the character's style as `glyphAlternate: {ch, gid, font, version}`, the face's PostScript name and version string: set in another font or version, or with the font missing, the character takes its default glyph. Both commands -refuse a character whose font is not available, and `text.setGlyphAlternate` refuses a glyph that is not one of the -character's alternates and a glyph that the character's style does not draw (All Caps, Small Caps or another numeral +refuse a character whose font is not available, and `text.setGlyphAlternate` refuses a glyph that is not one of those +and a glyph that the character's style does not draw (All Caps, Small Caps or another numeral system). Choosing the glyph a character already draws is not an edit. Character styles do not take `glyphAlternate`: `charStyle.new`, `charStyle.edit` and `charStyle.setAttrs` refuse it. @@ -8025,6 +8081,36 @@ system). Choosing the glyph a character already draws is not an edit. Character {"name":"run_command","arguments":{"command":"text.setGlyphAlternate","params":{"id":7,"start":2,"gid":1043}}} ``` +### Alternates as OpenType feature values + +`text.setGlyphAlternate`'s choice swaps a glyph id into one character and shapes that character apart from its neighbours. +A feature with a value keeps the text real and joined: `features`, `featureChanges` (`text.setRangeStyle`, +`text.setStyle`, `charStyle.*`) take `tag=N` for `swsh`, `ss01` to `ss20`, `salt`, `titl`, `pnum`, `lnum`, `sups`, +`subs`, `numr`, `dnom` and `aalt`, `N` from 0 to 65535, and the style's `features` list keeps the string: +`"salt=2"` draws the font's second alternate of every glyph in the run that has one (the OpenType alternate +substitution picks the glyph at that position of its set; a glyph without one, or with fewer alternates, keeps the +glyph it has). A plain `"salt"` is `"salt=1"` and is written so; `"salt=0"`, `"-salt"` and leaving it out are off. The +next value for a feature replaces the last one, in `features` and in `featureChanges`, and a tag the build does not know +(`salt=x`, `salt=65536`, `-salt=2`, `liga=2`) is refused with the unknown-feature error. SVG export writes the value as +CSS `font-feature-settings: "salt" 2`. + +Runs that differ only in their `features` are shaped together as one piece of text, each run's features over its own +characters: styling the middle letter of an Arabic word with `salt=2` leaves every other letter its initial, medial or +final form and the letters still join, as the same word typed with no style change. Runs that differ in font, size, +scale, case, language, script or direction are still shaped apart. + +`text.glyphAlternates` lists a character's alternates by the glyph the font's character map gives it; the Glyphs panel +instead lists those of the glyph the shaper makes for the character in its word (a medial letter's medial form), finds +for each the feature value that draws it there, and on a double-click runs one `text.setRangeStyle` on exactly that +character: `featureChanges` turns its other alternate features (`salt`, `aalt`, `swsh`, `ss01`–`ss20`) off and sets the +value (`["-ss01", "salt=2"]`), and `glyphAlternate: null` takes away a glyph choice that would otherwise keep drawing +its own glyph (`text.setRangeStyle` takes it; any other value is refused). It is one undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"features":["salt=2"]}}} +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"featureChanges":["-salt"]}}} +``` + ## New type in a Japanese interface While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em @@ -8996,8 +9082,10 @@ so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`p ``` 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), 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 @@ -9633,8 +9721,9 @@ it ends up in front), baked with `effect.expandAppearance`, then `object.pathfin ## Cutting Tools add-on -Cutting Tools is a built-in add-on that prepares art for cutting. Its command is `addon.cutting.bridges`; in the desktop -app it is Object › Plug-ins › Cutting Tools › Stencil Bridges…. +Cutting Tools is a built-in add-on that prepares art for cutting. Its commands are `addon.cutting.bridges` and +`addon.cutting.weed`; in the desktop app they are Object › Plug-ins › Cutting Tools › Stencil Bridges… and Weed Box and +Lines…. ### Stencil Bridges @@ -9685,6 +9774,44 @@ Crossing) and Keep Original. {"name":"run_command","arguments":{"command":"addon.cutting.bridges","params":{"width":3,"count":2,"direction":"shortest","keepOriginal":false}}} ``` +### Weed Box and Lines + +`addon.cutting.weed {margin?, box?, direction?, count?, spacing?, gap?, weight?, swatch?}` adds a weed box around the +selected art and weeding lines across the box, so the waste vinyl around the art lifts off in pieces +([what a weeding border is](https://help.arlon.com/what-is-a-weeding-border)). The ranges, defaults and rules below are +VectorCraft's own. The weed box is a rectangle `margin` points (0 to 100000, default 9) outside the art's bounds; +`box: false` leaves it out. The weeding lines run `"horizontal"`, `"vertical"` or `"both"` ways (default `"both"`) from +one side of the box to the other and divide it evenly: `count` lines each way (a whole number from 0 to 1000, default 1), +or with `spacing` instead, as few lines as keep them at most that far apart. A line is left out over the art's area and +within `gap` points of its outlines (0 to 100000, default 0: the lines stop at the outlines), so it ends in several +pieces, each a straight path. The lines and the box are stroked `weight` points wide (0.01 to 1000, default 0.3) in the +color swatch `swatch` (default `"[Registration]"`), with no fill. + +The art is measured by the outlines a cutter cuts. Closed paths enclose their area by their fill rule, so the hole of a +compound path filled even-odd gets lines too, and open paths are lines with no area. Type is measured by its glyph +outlines, a clip group by what shows inside its clipping path (an open clipping path clips as if closed), an image by +its frame, a placed file by the art it shows (by its box when the file can't be read), a symbol instance by its art (an +instance of a symbol the document doesn't have adds nothing) and a live object such as a blend by the art it draws. An +object selected inside a clip group is measured by what shows of it, and a clipping path selected on its own as its clip +group. Strokes, effects, hidden objects and objects in a hidden group or layer are left out. + +The result is one group named "Weeding" (the box is its member "Weed Box"), added on top of the current layer and +selected, as one undo step. While a clip group, or a group inside one, is edited in isolation, the group goes on top of +the container that holds the outermost clip group around it. With nothing selected, or with a selection that has no +outlines, the command fails. It also fails when the art's outlines, followed in straight segments, have more than +200000 points; when groups, symbol instances and live objects are nested more than 128 levels deep; when measuring the +clip groups' outlines against each other takes more than 40000000 tests of one segment or outline point against another, +counted over every clip group in the selection together and including the outlines a nested clip group hides; when, for the +lines one way, more than 10000000 pairs of a line and an outline segment within `gap` of it are found, or closed +outline segments are checked for crossings with the lines more than 10000000 times; when the lines would be more than +100000 pieces; and when no line fits outside the art while `box` is false. Result: `{id, box, lines}`: the group's id, +the weed box's id (null without one) and the number of pieces of weeding line. Weed Box and Lines… opens the command +dialog with Preview; a Spacing above 0 is used instead of the Count. + +```json +{"name":"run_command","arguments":{"command":"addon.cutting.weed","params":{"margin":"0.25 in","direction":"both","spacing":"2 in","gap":1}}} +``` + ## Color Effects add-on Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is diff --git a/docs/roadmap.md b/docs/roadmap.md index 56230e6da3..4cc08b406d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -146,7 +146,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | 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) | | 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, 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 | +| 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 | @@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 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. | | 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. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b172aefd7e..5ec97e34c1 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 (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 (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) +> +> 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. > > Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > @@ -725,6 +727,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. @@ -760,7 +763,7 @@ 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`. @@ -874,10 +877,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Units › East Asian Type (the bullet below). Then User Interface › Open Documents As Tabs (see Window › Arrange). Three stored options still do nothing yet (the audit below). - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. Under either wheel preference, Cmd/Ctrl with a touchpad zooms about the pointer. - Preferences › Type › Highlight Substituted Fonts (on by default) paints pink behind text drawn in a substitute for a missing font in every document, and turning it off removes that highlight everywhere (`prefs.set highlightSubstitutedFonts`); Document Setup keeps Highlight Substituted Glyphs ([help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html)). + - Preferences › User Interface › UI Scaling scales the interface ([help](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/modify-workspaces.html)). VectorCraft's own rule is that it leaves the art's size alone: at a given zoom a document point covers the same device pixels at every UI Scaling, so View › Actual Size shows the art at the same size whatever the UI Scaling. A click, a pinch or wheel zoom about the pointer, a pan, the Zoom tool's marquee, Fit, tiled panes, Pixel Preview and the Navigator's view box use the same mapping. Evidence: `canvas::tests_ui_scaling`, `tiled_documents_fit_their_panes_at_every_ui_scaling`, `the_view_box_outlines_the_canvas_at_every_ui_scaling`. Scores unchanged. - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only (Units › East Asian Type and User Interface › Open Documents As Tabs, stored only on the day of the audit, act now: East Asian Type since 2026-10-31, Open Documents As Tabs as the Window › Arrange bullet says); [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - 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`). @@ -1014,6 +1019,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. 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`). - **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. @@ -1179,7 +1185,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. - The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, 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: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. + The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, 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. The grid lists the selected character's alternate glyphs from `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. A double-click applies the feature that draws the variant, or a glyph choice if no supported feature draws it, as detailed below. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Native files keep a glyph choice with the face it was made in; another font or version draws the default glyph. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. + The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, 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 (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the default glyph comes first and the current glyph is highlighted); a variant no supported feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`, including positional alternates), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. It applies the selected letter's contextual alternates the same way, and applies a glyph no feature reaches whose name does not belong to another letter as a glyph choice; other glyphs are dimmed with the reason. An Arabic presentation form picked there inserts its base letter. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests` (including positional glyph choices, unlisted glyph name ownership, apply and Undo), `tests::textedit::feature_values_are_set_replaced_and_saved`. The OpenType panel's Stylistic Sets menu offers `ss01`–`ss20` sets present in the selected text's font, using the font's custom labels when available and numbered labels otherwise. Sets can be combined and deselected individually. With mixed fonts, the menu shows the first selected character's font's sets and applies the feature tag to all selected text; a font without that set draws unchanged. The mixed-font rule is a VectorCraft design choice. Behavior reference: [Add stylistic sets to selected text](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html) and the [OpenType specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt). `text.setStyle {stroke, strokeWidth}` sets the character stroke's color and weight of the selected type or of a range of its characters (the weight in document points, as `stroke.set` sets it), in the same undo step as the command's other changes. Tests: `tests::textedit::set_style_sets_the_character_stroke`. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. @@ -1249,6 +1256,7 @@ 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, 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`. @@ -1355,12 +1363,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mix the points in that model too. PDF and EPS write a gradient whose points are all CMYK, or all Gray in a CMYK file, as a CMYK image, which PDF's Output settings convert as they convert any CMYK image. Points in different models mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point), and a CMYK document stores the result of Expand as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; the sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt at the default 0.01 pt) rather than one cubic per flatten step, so exports stay close to a uniform stroke's path data; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. - - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web. + - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Pattern and Bristle brushes and on native Wayland and the web. Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>) and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). - **Pen tilt, bearing and rotation (part of #764):** Calligraphic and Scatter Brush Options offer Tilt, Bearing and Rotation beside Fixed, Random and Pressure. The Paintbrush records along each stroke how far the pen leans, which way, and how far its barrel is turned (kept with the path, saved, and joined when a stroke continues a path). A Calligraphic value on Tilt goes from value − variation with the pen lying flat to value + variation upright; on Bearing or Rotation it adds variation × the angle over 180°, so Angle on Bearing with a Variation of 180° turns the nib with the way the pen leans. A Scatter value goes from its first value to its second over the same ranges. A stroke without that input draws the brush's own value. On Linux under X11 and XWayland the app reads the tilt and an Art Pen's barrel rotation from XInput2, on macOS it reads them from AppKit's tablet events, and `path.freehand` points and `pointer_gesture` events carry them on every platform. The [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page defines the three options and does not say which way each one runs; that mapping is VectorCraft's own rule. Evidence: `tests::tilt_maps_to_roundness_along_the_stroke`, `tests::bearing_and_rotation_turn_the_nib`, `tests::scatter_values_follow_tilt_and_bearing` (vectorcraft-brush), `tests_brushsym::paintbrush_records_the_pens_tilt_on_the_stroke` (vectorcraft-engine), `tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` and `mac_tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` (vectorcraft). Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value. - Art Brush Options' Width has a Fixed or Pressure menu, from the pen tablet options of the Width pop-up menu on the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy). With Pressure, the art is Width wide at the lightest pen pressure and the second width at the heaviest, following the pressure the Paintbrush recorded along the stroke, and half way between them on a stroke without recorded pressure (`brush.options` `width_mode` and `width_max`); a stroke inside the art takes the width at the middle of its object. The page does not say how the pressure maps to the width; taking the Scatter brush's minimum and maximum rule and the stroke rule are VectorCraft's own. Stylus Wheel, Tilt, Bearing and Rotation are not built. + Art Brush Options' Width has a Fixed or Pressure menu, from the pen tablet options of the Width pop-up menu on the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy). With Pressure, the art is Width wide at the lightest pen pressure and the second width at the heaviest, following the pressure the Paintbrush recorded along the stroke, and half way between them on a stroke without recorded pressure (`brush.options` `width_mode` and `width_max`); a stroke inside the art takes the width at the middle of its object. The page does not say how the pressure maps to the width; taking the Scatter brush's minimum and maximum rule and the stroke rule are VectorCraft's own. The menu also offers Tilt, Bearing and Rotation: the art goes from Width with the pen lying flat to the second width upright, and from Width at −180° to the second width at 180° of the pen's bearing or barrel rotation, as Scatter Brush Options' modes do, and half way on a stroke without that input; the page does not say which way each runs, so that mapping is VectorCraft's own. Evidence: `tests::art_brush_width_follows_tilt_bearing_and_rotation` (vectorcraft-brush). Stylus Wheel is not built. Scatter, Art and Pattern Brush Options have the Tips button for Colorization that the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes, beside Method. It opens Colorization Tips, which shows each method's name and what it does beside a sample of the brush, as OK would make it, painted with that method on a blue stroke; Hue Shift's sample uses the dialog's Key Color. `ui.dialog.set {field: "tips", value: true}` opens it. Screen readers name the button, the popover and each sample. Hue Shift set by `brush.new` or `brush.options` without a key color takes the color the art's fills and strokes use most, as Brush Options already offers it, following the Brushes page, where the key color defaults to the color that stands out most in the art (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>). The page does not state how prominence is measured. Counting the fills and strokes that use each solid color, with the first one found winning a tie and black for art with no solid color, is VectorCraft's own rule. A `brush.options` edit that gives only the method keeps the brush's key color, and one that gives only an art brush's start guide keeps its end guide. Evidence: `tests_brushsym::hue_shift_without_a_key_takes_the_most_used_art_color`, `tests_brushsym::art_brush_options_keep_the_scale_fields_an_edit_leaves_out`. Bristle Brush Options and `brush.new` take a bristle brush name of at most 31 characters, as the Brushes page states for the Bristle Brush Options. The Name field stops taking characters at 31, and `brush.new` and `brush.options` refuse a longer new name with an error that names the limit. The page does not say how the limit counts characters or what happens to longer names already saved; counting characters and the number added to a taken name, keeping longer saved names, and leaving `brush.duplicate` unlimited are VectorCraft's own rules. A bristle brush that a document or a saved file already holds under a longer name keeps it, and the other brush types take a name of any length. Test: `bristle_brush_names_have_at_most_31_characters` in `tests/brushsym.rs`. Public reference: [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) (archived copy). @@ -1596,7 +1604,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur anchor or with Alt held over a path (convert) and over another open path's end (join). The Eyedropper has an original pipette glyph with its tip at the hotspot; Use Precise Cursors restores the crosshair. The same glyph feeds OS bitmaps and painted cursors. DPI/hotspot and preference checks in `cursors.rs` and `tests/prefs.rs` cover #1269; - actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the root document canvas now requests native AppKit delivery of the existing artwork, with aligned 1×/2× representations and a visible system fallback (#2292). `cursors/native_request` and `canvas` tests cover pixel/hotspot alignment, preferences and fallback routing; `mac_cursor` tests cover real AppKit image construction and the ownership protocol against a fake backend. The host releases on native-menu tracking and loss of eligibility; detached document viewports retain the visible system fallback. The pre-platform-output rectangle strategy, real focus/menu/handback behavior, mixed displays, Spotlight and native full-screen Spaces remain unverified; #2020 and #2292 are not declared closed. + actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the root document canvas now requests native AppKit delivery of the existing artwork, with aligned 1×/2× representations and a visible system fallback (#2292). While the host owns the cursor it sets it again on every pass instead of comparing it with `NSCursor.currentCursor`, which is the app's last set cursor rather than the one on screen, so the tool's cursor no longer gives way to the system arrow when the pointer moves (#6362). `cursors/native_request` and `canvas` tests cover pixel/hotspot alignment, preferences and fallback routing; `mac_cursor` tests cover real AppKit image construction and the ownership protocol against a fake backend. The host releases on native-menu tracking and loss of eligibility; detached document viewports retain the visible system fallback. The pre-platform-output rectangle strategy, real focus/menu/handback behavior, mixed displays, Spotlight and native full-screen Spaces remain unverified; #2020 and #2292 are not declared closed. In a rotated view the resize and rotate cursors turn with the view, to the nearest 45°. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502). Hard-edge rendering, including the internal hard-edge frame of Art Optimized supersampled raster exports, draws a knockout element with one opaque solid fill once instead of erasing and adding it through separate layers. Analytical antialiasing keeps the existing compositor and rounding. Synthetic path/compound-hole tests compare exact pixels against the general knockout path across precision, supersampling, translucent parent groups and backdrop blending; translucent fills retain their knockout behavior. @@ -1985,6 +1993,10 @@ A VectorCraft addition, not a feature of the reference app (it has no command fo A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting Tools › Stencil Bridges… cuts strips of a set width (0.01 to 1000 pt, 2 by default) out of the selected art so that a stencil cut from it keeps its islands, such as the inside of an O or a hole in a shape, attached to the sheet ([stencil islands and bridges](https://en.wikipedia.org/wiki/Stencil)). Every island is joined to the sheet, directly or through another island, by the shortest crossings that fit, then gets more bridges spread around it up to the count asked for (1 to 16); bridges run vertically, horizontally or along the shortest crossing among 12 directions 15° apart. The art is measured by the area its outlines enclose (hidden objects left out), type by its glyph outlines without converting the type, and becomes one compound path with the gaps cut; the selected objects that make up the art go to a new hidden layer, Stencil Original, or are replaced, and selected objects with no outlines stay where they are. Art with no islands is left as it is with a message and no undo step, islands the cut result still encloses are counted and reported, and the run is one undo step. Tests: `addons::stencil::tests` in `vectorcraft-pathops` (an O, two holes, nested islands, curved outlines, width, direction, the shortest crossing at an angle, more bridges per island and their spacing, narrow islands, slivers, outlines that touch at a corner, the curve tolerance, open outlines, limits and the work budget), `cmd::addons::cutting::stencil::tests` in `vectorcraft-engine` (the hidden layer and undo, replacing, width with units, type, hidden group members, objects with no outlines, the result's paint, kind and place in the stacking order, no islands, narrow islands, bad params refused), `stencil_bridges_opens_its_dialog_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Stencil Bridges. +## Cutting Tools add-on: Weed Box and Lines (2026-11-12) + +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Cutting Tools › Weed Box and Lines… (`addon.cutting.weed`) adds a weed box and weeding lines around the selected art for vinyl cutting ([what a weeding border is](https://help.arlon.com/what-is-a-weeding-border)). The rest is VectorCraft's own: the box is a rectangle at a margin around the art, and the lines run across it horizontally, vertically or both ways, by count or by spacing, and stop at the art's outlines or at a gap from them. The box and the lines are straight paths stroked in a chosen swatch ([Registration] by default), in one group named Weeding on top of the current layer, added as one undo step. The art is measured by the outlines a cutter cuts: compound paths by their fill rule, type by its glyph outlines, clip groups by what shows, images by their frames, placed files by the art they show, symbol instances by their art, and blends and other live objects by the art they draw. The dialog has Preview. Tests: `cmd::addons::cutting::weed::tests` in `vectorcraft-engine`, `weed_box_and_lines_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Weed Box and Lines. + ## Path Precision Tools add-on: Divide into Dashes (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Divide into Dashes… cuts the selected paths into separate open paths along the curve (arc length, not straight distance): each `dash` long (0.01 to 1000000 pt, 10 by default) with `gap` between them (0 by default: the pieces touch); the gap pieces are left out unless Remove Gaps is off. With direct-selected segments (or anchors) only those runs are divided, the pattern running on across segments that follow one another, and the rest of such a path stays as pieces of its own. Fit to Corners and Ends gives every run between corners and ends a whole number of dashes, all stretched or squeezed alike so it starts and ends on a dash (a closed path without corners holds whole periods); otherwise the lengths are exact and each run's last piece is what is left. Every piece keeps the path's appearance and is selected; one undo step; at most 100000 pieces (more is refused). The dialog takes lengths in any unit and previews on the canvas. Tests: `dashes::tests` in `vectorcraft-pathops` (exact and fitted lengths, curves measured along them, picked segments on open and closed paths, the piece cap and bad patterns), `divide_into_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (gaps removed or kept, fitted lengths, the stroke kept, one undo step, selected segments only, bad params and too many pieces refused), `divide_into_dashes_opens_its_dialog_previews_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on. @@ -2085,12 +2097,16 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 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. | | 2026-11-12 | minor | UI chrome: a numeric-field focus click released more than 1 pt from its press keeps the caret; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326); scores and hours unchanged. | | 2026-11-03 | minor | File Info on open: an imported document's own title (an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name (tab, Save As, default export names) stays the file's name (#3626); scores and estimates unchanged. | | 2026-11-12 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates 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 | Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does (Smart Guides, Snap to Point and the ruler guides, pixels, the grid); evidence updated; scores unchanged. | +| 2026-11-11 | minor | Interim macOS canvas cursor delivery (#2292): the tool's cursor stays after the pointer moves (#6362); tool cursors evidence updated; scores and hours unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Envelopes: with Distort Appearance, type's strokes (characters' and appearance) paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them; paths' strokes, geometry effects, gradients and patterns bend as before; also in an envelope inside an envelope; opacity masks bend as other art; Expand keeps type's strokes as strokes; the visual bounds of envelopes, blends and repeats and the reach of symbol art count type's character strokes as type's own bounds do. Changes how VectorCraft's own envelopes of stroked type draw. Advanced art evidence and `.ai` import evidence updated (#6644); scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 30547d5a17..c6a2d61a74 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -2,6 +2,8 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; 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: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) +Glyphs review (#6084): a positional variant no supported feature draws is accepted as a glyph choice. Entire Font respects the selection's variable axes and font version; the default cell can reset an unlisted choice without GSUB alternates. Contextual choices use the supplying face, including fallback. Feature-only boundaries preserve ligature clusters and Arabic mark attachment. Encoded characters insert unchanged except Arabic presentation forms with a joining base letter; normalization reports success only after insertion succeeds. Latest follow-up regressions await runtime verification. Scores and estimates unchanged. + Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the code and tests (`crates/text`, `crates/engine/src/cmd/type*.rs`, `textstyles.rs`, `crates/tools/src/text.rs`) @@ -30,7 +32,8 @@ against Illustrator's public documentation; **estimated** unless noted. | Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | 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, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | +| 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). | | 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 | @@ -81,6 +84,10 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Glyphs review (#6084): validate contextual glyph choices against their supplying face, including fallback and overflow; keep feature-only boundaries inside ligatures and Arabic mark clusters while retaining drawing-attribute splits; preserve insertion failures instead of reporting normalization success. Deterministic engine, shaping and panel regression sources added; ligature range assertions use the fixture's unencoded glyph identity. Runtime proof pending. Scores and estimates unchanged. | +| 2026-11-12 | minor | Glyphs review (#6084): Entire Font resolves the selected font's version and variable axes; the default cell remains available to reset an unlisted choice with no listed alternates. Character insertion normalizes only Arabic presentation forms, preserving ordinary characters whose glyphs share Arabic substitution provenance. Add deterministic panel apply/reset/Undo regressions without OS-font dependencies. Scores and estimates unchanged. | +| 2026-11-09 | minor | Glyphs review (#6084): the engine accepts a positional alternate as a glyph choice when no supported feature draws it, not only an isolated character's alternates; both panel views exercise apply and Undo. OpenType style options preserve only a valid last on-value when enabling a stylistic set. Scores and estimates unchanged. | +| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates, or that no feature reaches and the font's names do not give to another letter, and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. | | 2026-11-11 | minor | Kerning at the start of a line (#5111): at the Type tool's caret before the first character of the text, of a paragraph or after a forced line break, the Kerning field, Alt+arrows and Reset Tracking and Kerning set the space before the character after the caret (`CharStyle::kerning_before`, `kerningBefore` in `text.setRangeStyle`), which moves it and the rest of its line, in horizontal, right-to-left and vertical type and in SVG export. They did nothing at the text's start and kerned the invisible line break at a paragraph's. Scores and estimates unchanged. | | 2026-11-11 | minor | Type in envelopes: its strokes (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them; with Distort Appearance they bent with the art as their filled outlines. The same holds in an envelope inside an envelope. Geometry effects on type's fills and strokes, and an opacity mask on type, still bend; Expand gives the bent outlines with the strokes on them. The visual bounds of envelopes, blends and repeats count type's character strokes as type's own visual bounds do. This changes how VectorCraft's own envelopes of stroked type draw (#6644). Score unchanged (estimated) | | 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bb2857d119..81cfe1e0e1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag 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: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: 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; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; 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; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **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: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag 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: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: 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; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; 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; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -332,7 +332,7 @@ tools, never checked side by side) isn't captured row by row. | Tool double-click opens its options | done | | | Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | | | Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display unverified; while Caps Lock is on, the tools that Use Precise Cursors changes show the crosshair, and the saved preference does not change (the desktop app reads the lock state on macOS, Windows and Linux X11, not on native Wayland; `view.capsLock` reads or sets it); a Use Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) | -| macOS canvas cursor delivery (#2292) | partial | The UI prepares the existing glyphs as aligned 1×/2× RGBA requests; the desktop host builds AppKit cursors and owns cursor rectangles for the root document window. Detached document viewports and requests without a usable native bitmap retain the visible system fallback; precise-cursor and UI-scale preferences retained. Headless regressions cover routing, pixels/hotspots and lifecycle; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain unverified. Wayland still paints its glyphs. | +| macOS canvas cursor delivery (#2292) | partial | The UI prepares the existing glyphs as aligned 1×/2× RGBA requests; the desktop host builds AppKit cursors and owns cursor rectangles for the root document window. While it owns the cursor, the host sets it again on every pass, so the tool's cursor stays when the pointer moves (#6362). Detached document viewports and requests without a usable native bitmap retain the visible system fallback; precise-cursor and UI-scale preferences retained. Headless regressions cover routing, pixels/hotspots and lifecycle; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain unverified. Wayland still paints its glyphs. | | Every tool's modifiers checked against the shortcut reference | missing | this is the fidelity pass | ## Keyboard shortcuts @@ -526,6 +526,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates 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 | Ruler origin: the corner drag snaps as a drawn point does, onto the art's and the artboards' anchors, centres, edges and paths, and with Snap to Point along the ruler guides; scores and estimates unchanged | +| 2026-11-11 | minor | macOS canvas cursor delivery (#2292): while the host owns the cursor it sets it again on every pass, so the tool's cursor stays after the pointer moves (#6362); `mac_cursor` ownership test updated; scores and estimates unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). |